Bienvenue dans La Tour Infernale, un jeu Roblox que nous allons construire ensemble ! Chaque participant fabrique sa propre partie du jeu, et à la fin, tous les parcours se rejoignent au sommet d’une grande tour.
L’objectif du jeu
L’objectif est de construire un jeu à plusieurs :
Chaque participant construit un chemin semé d’embûches, mais réalisable, pour rejoindre la tour.
Une fois les parcours terminés, tous les joueurs devront rejoindre le haut panoramique de la tour pour admirer les différents projets de toute l’équipe.
L’état d’esprit : exigeant mais juste
Ton parcours doit respecter une règle d’or :
Difficile, oui. Impossible, non !
Construis un parcours réalisable : un bon joueur qui se concentre doit pouvoir le réussir.
Mais rends-le difficile pour les autres joueurs : pièges, obstacles, sauts risqués… tout est permis (tant que c’est possible) !
Teste ton propre parcours encore et encore : si toi tu ne peux pas le passer, personne ne pourra !
Démarrer : trouver son spawn
Chaque joueur dispose d’un spawn (point de départ) pour débuter son chemin.
Repère le numéro de ton poste de travail (par exemple 07).
Dans l’explorateur (à droite de Roblox Studio), ouvre Workspace, puis le dossier spawns.
Cherche le spawn qui porte le numéro de ton poste : il s’appelle spawn123codageXX, où XX est ton numéro de poste.
Exemple : poste 07 → spawn123codage07
Sélectionne ton spawn, puis appuie sur la touche F du clavier : tu es automatiquement positionné sur ton spawn dans l’espace du jeu. Tu peux commencer à construire !
Tester son parcours
Pour construire un bon parcours, il faut se mettre en test régulièrement :
Lance ton jeu en test pour essayer ton parcours.
Vérifie que chaque saut est faisable et que chaque piège est évitable.
Améliore, re-teste, recommence : c’est comme ça qu’on fabrique un parcours réussi !
Jouer à plusieurs : le « Team Test »
Quand tout le monde est prêt, on lance une partie en Team Test :
Chaque joueur est positionné aléatoirement sur le spawn d’un autre joueur.
Tu découvres donc le parcours construit par un de tes camarades… sans savoir à l’avance lequel !
À toi de franchir ses embûches pour rejoindre la tour.
Règles du jeu
Crée ton Folder dans le workspace pour ranger toutes tes créations graphiques.
Crée ton Folder dans ServerScriptService pour ranger tes scripts.
Réalise un parcours d’embûches pour accéder à la tour, puis à la vue panoramique.
Ton parcours doit être réalisable : un joueur déterminé doit pouvoir le finir.
Ton objectif est de le rendre difficile pour les autres joueurs : plus c’est dur (mais possible), plus c’est fun !
Respecte le travail des autres : ne modifie que ton parcours et ton spawn.
Pas de contenu inapproprié, pas de triche : on joue dans les règles de Roblox.
Bonne construction, et rendez-vous au sommet de la tour !
Objectif : Créer un labyrinthe avec des portes à ouvrir
Ouvrir une porte avec un bouton poussoir :
Construis une porte avec un bouton poussoir pour l’ouverture :
Avec un ClickDetector sur le bouton :
Intercepter le click sur le ClikDetector dans le script :
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local clickDetector = door.PushButton.ClickDetector
clickDetector.MouseClick:Connect(function(player)
print("Le joueur "..player.Name, "a demandé l'ouverture de la porte")
end)
Lance le jeu et click sur le bouton poussoir, un message s’affiche dans la sortie :
Puis modifie ton script pour « ouvrir la porte » en rendant invisible la Part de la porte :
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local clickDetector = door.PushButton.ClickDetector
clickDetector.MouseClick:Connect(function(player)
print("Le joueur "..player.Name,
"a demandé l'ouverture de la porte")
door.Door.Transparency = 1
end)
Mais tu peux constater que le joueur ne passe pas la porte, pour éviter ce problème tu peux éviter la collision entre la porte et le joueur :
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local parts= door:GetChildren()
function openDoor(player)
print("Le joueur "..player.Name,
"a demandé l'ouverture de la porte")
door.Door.Transparency = 1
door.Door.CanCollide = false
end
Deux possibilités pour refermer la porte :
un autre click par le joueur
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local clickDetector = door.PushButton.ClickDetector
clickDetector.MouseClick:Connect(function(player)
print("Le joueur "..player.Name,
"a demandé l'ouverture de la porte")
if door.Door.CanCollide then
door.Door.Transparency = 1
else
door.Door.Transparency = 0
end
door.Door.CanCollide = not door.Door.CanCollide
end)
fermeture automatique après un certain temps
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local clickDetector = door.PushButton.ClickDetector
clickDetector.MouseClick:Connect(function(player)
print("Le joueur "..player.Name,
"a demandé l'ouverture de la porte")
door.Door.Transparency = 1
door.Door.CanCollide = false
task.wait(2)
door.Door.Transparency = 0
door.Door.CanCollide = true
end)
Ouverture de la porte avec deux boutons poussoirs :
Comment faire si deux boutons poussoirs de chaque coté de la porte :
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local parts= door:GetChildren()
function openDoor(player)
print("Le joueur "..player.Name,
"a demandé l'ouverture de la porte")
if door.Door.CanCollide then
door.Door.Transparency = 1
else
door.Door.Transparency = 0
end
door.Door.CanCollide = not door.Door.CanCollide
end
-- récupération de tous les clickDetectors
for _, part in ipairs(parts) do
local clickDetector =
part:FindFirstChild("ClickDetector")
if clickDetector then
clickDetector.MouseClick:Connect(function(player)
openDoor(player)
end)
end
end
Fermeture automatique de la porte
Comment refermer la porte automatiquement au bout d’un laps de temps :
-- Récupération de la porte
local door = script.Parent
-- Récupération du clickDetector
local parts= door:GetChildren()
function closeDoor()
if not door.Door.CanCollide then
door.Door.Transparency = 0
door.Door.CanCollide = true
end
end
function openDoor(player)
print("Le joueur "..player.Name,
"a demandé l'ouverture de la porte")
if door.Door.CanCollide then
door.Door.Transparency = 1
task.delay(2, closeDoor)
else
door.Door.Transparency = 0
end
door.Door.CanCollide = not door.Door.CanCollide
end
-- récupération de tous les clickDetectors
for _, part in ipairs(parts) do
local clickDetector = part:FindFirstChild("ClickDetector")
if clickDetector then
clickDetector.MouseClick:Connect(function(player)
openDoor(player)
end)
end
end
Ouvrir une porte par une zone de détection :
Crée un part invisible autour de la porte qui aura la fonction d’une zone de détection de l’entrée ou la sortie du joueur :
Modifie les propriétés de la zone de détection
Supprime l’option de la possibilité de la collision :
Rend transparent la zone de collision :
Et évidement ancre la zone de détection :
Modifie le script pour détecter l’entrée et la sortie du joueur de la zone de détection :
-- Récupération de la porte
local door = script.Parent
local detectionZone = door.DetectionZone
-- Le joueur rentre dans la zone de détection
detectionZone.Touched:Connect(function()
print("entrée")
end)
-- Le joueur sort de la zone de détection
detectionZone.TouchEnded:Connect(function()
print("sortie")
end)
Ouvrir et fermer la porte avec la détection du joueur :
-- Récupération de la porte
local door = script.Parent
local detectionZone = door.DetectionZone
-- Le joueur rentre dans la zone de détection
detectionZone.Touched:Connect(function()
print("entrée")
if door.Door.CanCollide then
door.Door.Transparency = 1
door.Door.CanCollide = false
end
end)
-- Le joueur sort de la zone de détection
detectionZone.TouchEnded:Connect(function()
print("sortie")
if not door.Door.CanCollide then
door.Door.Transparency = 0
door.Door.CanCollide = true
end
end)
Rajoute une temporisation avant la fermeture de la porte pour éviter des ouvertures et des fermetures intempestives quand le joueur reste sur le bord de la zone de détection :
-- Récupération de la porte
local door = script.Parent
-- Récupération de la zone de détection
local detectionZone = door.DetectionZone
-- Temps d'attente avant fermeture de la porte
local TIMEOUTCLOSE = 2
-- Le joueur rentre dans la zone de détection
detectionZone.Touched:Connect(function()
print("entrée")
if door.Door.CanCollide then
door.Door.Transparency = 1
door.Door.CanCollide = false
end
end)
-- Le joueur sort de la zone de détection
detectionZone.TouchEnded:Connect(function()
print("sortie")
if not door.Door.CanCollide then
task.spawn(function()
door.Door.CanCollide = true
task.wait(TIMEOUTCLOSE)
if door.Door.CanCollide then
door.Door.Transparency = 0
end
end)
end
end)
Rajouter un son d’ouverture
Ouvrir la boîte à outils pour trouver un son d’ouverture de porte :
Choisir Audio :
Saisir une recherche :
Choisir un son pour le mettre dans l’arborescence du workspace de la porte :
Puis modifie le script pour rajouter le son lors de l’ouverture de la porte :
-- Récupération de la porte
local door = script.Parent
-- Récupération de la zone de détection
local detectionZone = door.DetectionZone
-- Temps d'attente avant fermeture de la porte
local TIMEOUTCLOSE = 2
local soundDoor = door.door_open
-- Le joueur rentre dans la zone de détection
detectionZone.Touched:Connect(function()
print("entrée")
if door.Door.CanCollide then
soundDoor:Play()
door.Door.Transparency = 1
door.Door.CanCollide = false
end
end)
-- Le joueur sort de la zone de détection
detectionZone.TouchEnded:Connect(function()
print("sortie")
if not door.Door.CanCollide then
task.spawn(function()
door.Door.CanCollide = true
task.wait(TIMEOUTCLOSE)
if door.Door.CanCollide then
door.Door.Transparency = 0
end
end)
end
end)
Ouvrir une porte par une rotation :
Créez une porte à partir d’un part :
Rangez le part et renommez le dans l’explorateur :
Puis créez avec un part une poignée :
Créez une contrainte rigide entre la porte et la poignée :
Sélectionnez la poignée puis la porte puis demande une « soudure » entre les deux éléments :
Demandez un clickDetector sur la poignée :
Modifiez le pivot de la porte :
Créez un script pour ouvrir la porte :
-- Récupération du clickDetector
local clickDetector = script.Parent.Handle.ClickDetector
-- Récupération de la porte
local door = script.Parent.Door
-- Récupération de la distance d'activation du clickdetector
local distance = clickDetector.MaxActivationDistance
-- Fonction de rotation de la porte
local function doorRotation(door, push, rotation)
clickDetector.MaxActivationDistance = 0
if push ~= 0 then
for i = 1, (rotation//math.abs(push)) do
door:PivotTo(door:GetPivot() *
CFrame.Angles(0, math.rad(push), 0))
wait(0.01)
end
end
clickDetector.MaxActivationDistance = distance
end
-- Fonction de détection de clic di clickdetector
clickDetector.MouseClick:Connect(function()
doorRotation(door, 1, 90)
end)
Refermez la porte au bout de x secondes, rajouter ces deux instructions :
-- Fonction de détection de clic
clickDetector.MouseClick:Connect(function()
doorRotation(door, 1, 90)
task.wait(5)
doorRotation(door, -1, 90)
end)
Modifier le script pour un déplacement de la porte vers le haut :
local clickDetector = script.Parent.Handle.ClickDetector
local door = script.Parent.Door
local distance = clickDetector.MaxActivationDistance
local function doorPushUp(door, push)
door.CFrame += Vector3.new(0,push,0)
while door.CFrame.Position.Y>5 and door.CFrame.Position.Y<15 do
wait(0.01)
door.CFrame += Vector3.new(0,push,0)
end
end
clickDetector.MouseClick:Connect(function()
clickDetector.MaxActivationDistance = 0
doorPushUp(door, 1)
task.wait(5)
doorPushUp(door, -1)
clickDetector.MaxActivationDistance = distance
end)
Crée des zones de danger qui vont réduire la santé des joueurs lorsqu’ils les touchent. Pour commencer, apprend à créer une grande partie invisible au niveau de l’eau, de sorte que tomber dans celle-ci entraine la disparition du joueur et le fasse recommencer au début du jeu.
Pour créer cette zone de danger :
Dans la fenêtre de l’Explorateur , ajoute un nouveau dossier dans le dossier Monde , puis renomme-le : Hazards .
Dans le dossier Hazards , insére une partie bloc et renomme-la Hazard :
Sur le + à droite de World demande à insérer un Model :
Puis renomme ce model en Hazards :
Puis dans l’onglet MODELE, demande un nouveau Elément Bloc :
Puis glisse ce nouvel objet sous Hazards :
Renomme le Hazard :
Déplace et met à l’échelle la zone de danger pour couvrir l’eau autour des plates-formes. Par exemple, avec ces données :
Puis modifie ces propriétés pour que la zone de danger soit invisible et que les joueurs puissent le traverser :
Règle la transparence sur 1. Cela rend le danger invisible, de sorte que l’eau réelle semble être le danger:
Désactive CanCollide . Cela indique au moteur ROBLOX que les autres parties peuvent traverser le danger sans interruption, ce qui signifie que les joueurs peuvent tomber à travers le danger.
Active Ancre . Cela indique au moteur ROBLOX de ne jamais modifier la position de la zone de danger.
Crée un script dans ServerScriptService , puis renommez-le en HazardService :
Remplace le code par défaut par le code suivant :
local workspace = game:GetService("Workspace")
local hazards = workspace.World.Hazards:GetChildren()
local function onHazardTouched(player, hazard)
if not hazard:GetAttribute("Enabled") then
return
end
local character = player.Parent
local humanoid = character and character:FindFirstChild("Humanoid")
if humanoid then
humanoid.Health = 0
end
end
for _, hazard in hazards do
hazard:SetAttribute("Enabled", true)
hazard.Touched:Connect(function(player)
onHazardTouched(player, hazard)
end)
end
Joue et tombe dans l’eau autour des plateformes :
Rajoute des animations sur les zones de danger
Mettre le feu
Installe une zone où un incendie se déclenche aléatoirement, à ne pas traverser au péril de perdre une vie.
Dans la boîte à outil recherche une animation d’un feu : fire :
Sélectionne l’animation Realistic Fire With Smoke, un feu avec de la fumée. Puis glisse cette animation sous Hazards :
Positionne ton incendie à un endroit stratégique de ton jeu :
Modifie le script HazardService en ajoutant cette programmation qui va sur un intervalle de temps aléatoire allumer ou éteindre le feu en rendant ainsi la zone dangereuse ou pas.
local workspace = game:GetService("Workspace")
local hazards = workspace.World.Hazards:GetChildren()
local function onHazardTouched(player, hazard)
if not hazard:GetAttribute("Enabled") then
return
end
local character = player.Parent
local humanoid = character and character:FindFirstChild("Humanoid")
if humanoid then
humanoid.Health = 0
end
end
for _, hazard in hazards do
hazard:SetAttribute("Enabled", true)
hazard.Touched:Connect(function(player)
onHazardTouched(player, hazard)
end)
end
local runService = game:GetService("RunService")
local interval = math.random(5, 10)
local function updateFire()
for _, hazard in hazards do
local fire = hazard:FindFirstChildOfClass("ParticuleEmitter")
if fire then
fire.Enabled = not fire.Enabled
hazard.SetAttribut("Enabled", fire.Enabled)
end
end
end
Le joueur doit attendre la fin de l’incendie pour pouvoir passer.
Si tu le souhaites, tu peux installer plusieurs zones de feu dans ton jeu :
Une explosion
Rajoute un bloc sous Hazards et renomme le Explosion :
Puis utilise le + à droite de Explosion pour rajouter ParticleEmitter :
Que tu renommes Explosion :
Puis modifie les propriétés suivantes :
Teste le résultat et reviens sur les paramètres pour obtenir le résultat que tu souhaites :
Puis rends le socle transparent et canCollide à False :
Recommence pour une nouvelle zone de danger avec ce nouvel effet comme celui-ci :
Objectif : Créer un part invisible qui apparaît lorsque le joueur clique sur un autre part avec un clickDetector.
Instructions :
Créer un part avec ClickDetector : Ajoutez un part, renommez-la, et ajoutez un « ClickDetector ».
Créer un part invisible : Ajoutez un autre part et définissez Transparency = 1 et CanCollide = false.
Script d’apparition : Lorsque le joueur clique sur le part avec le « ClickDetector », le deuxième part apparaît.
Indices :
Utilisez myClickDetector.MouseClick:Connect(function() pour détecter le clic.
Comment coder un ClickDetector sur le part :
local myFirstPart = script.Parent
local clickDetector = myFirstPart.ClickDetector
local mySecondPart =myFirstPart.MySecondPart
clickDetector.MouseClick:Connect(function()
mySecondPart.Transparency=0
end)
Objectif : Créer un part invisible qui apparaît lorsque le joueur clique sur un autre part avec un proximityPrompt :
Comment coder un ProximityPrompt sur le part
local myFirstPart = script.Parent
local proximity = myFirstPart.ProximityPrompt
local mySecondPart =myFirstPart.MySecondPart
proximity.Triggered:Connect(function(player)
mySecondPart.Transparency=0
end)
Objectif : Créer une part qui tue le joueur lorsqu’il la touche.
Instructions :
Créer un part rouge : Ajoutez un part, colorez-le en rouge et utilisez le matériau « Néon ».
Ajouter un script : Créez un script dans le part.
Script de mort : Lorsque le joueur touche le part, il meurt.
Indices :
Utilisez [Nom du part].Touched:Connect(function(player) pour détecter le contact.
Utilisez character = player.Parent pour obtenir le personnage du joueur.
Utilisez humanoid = character and character:FindFirstChild("Humanoid") pour obtenir l’objet Humanoid du joueur.
Tuer le joueur : humanoid.Health = 0
Santé du joueur à 100 au départ du jeu :
print(humanoid.Health)
Réduire la santé du joueur :
humanoid.Health = humanoid.Health – 5 ou humanoid:TakeDamage((5))
Le joueur meurt quand sa santé est à zéro :
humanoid.Health = 0
Un exemple de code avec une mort rapide :
local deadlyPart = script.Parent
deadlyPart.Touched:Connect(function(player)
local character = player.Parent
local humanoid = character and character:FindFirstChild("Humanoid")
if humanoid then
humanoid.Health = humanoid.Health - 5
end
end)
Un exemple de code avec une mort lente :
local deadlyPart = script.Parent
local startTime = 0
deadlyPart.Touched:Connect(function(player)
local character = player.Parent
local humanoid = character and character:FindFirstChild("Humanoid")
if humanoid then
if startTime > 0 and (tick() - startTime) > 0.2 then
startTime = 0
end
if startTime == 0 then
print(humanoid.Health)
humanoid.Health = humanoid.Health - 5
startTime = tick()
end
end
end)
Comment gérer un message d’indication au joueur par un Chat :
Rajouter un ModuleScript ChatScript sous ReplicatedStorage :
Puis coder dans ce script :
local ChatScript = {}
function ChatScript.sendMessage(player, message)
local character = player.Character or player.CharacterAdded:Wait()
if character then
local head = character:WaitForChild("Head")
if head then
game:GetService("Chat"):Chat(head, message)
end
end
end
return ChatScript
Rajouter dans votre script qui gère votre interface les instructions suivantes lorsque le joueur referme la fenêtre :
-- récupération du service de chat
local chat = require(game.ReplicatedStorage.ChatScript)
-- récupération du player dans un localScript
local player = game:GetService(« Players »).LocalPlayer
chat.sendMessage(player, "niveau : 1")
Programmation pour ouvrir ou fermer la fenêtre de contrôle dans un LocalScript :
local controFrame = script.Parent.Parent:WaitForChild("ControlFrame")
local openButton = script.Parent
local closeButton = controFrame.QuitButton
-- Ouverture de la fenêtre de contrôle
openButton.MouseButton1Click:Connect(function()
controFrame.Visible = not controFrame.Visible
openButton.Visible = false
end)
-- Fermeture de la fenêtre de contrôle
closeButton.MouseButton1Click:Connect(function()
controFrame.Visible = not controFrame.Visible
openButton.Visible = true
end)
controFrame.Visible = false
Comment déplacer un objet Roblox :
myPart.Position += Vector3.new(10,0,0)
Exemple de code pour déplacer le part en modifiant ton localScript :
local controFrame = script.Parent.Parent:WaitForChild("ControlFrame")
local openButton = script.Parent
local closeButton = controFrame.QuitButton
local downButton = controFrame.DownButton
local leftButton = controFrame.LeftButton
local rightButton = controFrame.RightButton
local upButton = controFrame.UpButton
local myPart = game.Workspace:WaitForChild("MyPart")
-- Ouverture de la fenêtre de contrôle
openButton.MouseButton1Click:Connect(function()
controFrame.Visible = not controFrame.Visible
openButton.Visible = false
end)
-- Fermeture de la fenêtre de contrôle
closeButton.MouseButton1Click:Connect(function()
controFrame.Visible = not controFrame.Visible
openButton.Visible = true
end)
-- Déplacement du part
leftButton.MouseButton1Click:Connect(function()
myPart.Position += Vector3.new(-10,0,0)
end)
rightButton.MouseButton1Click:Connect(function()
myPart.Position += Vector3.new(10,0,0)
end)
upButton.MouseButton1Click:Connect(function()
myPart.Position += Vector3.new(0,2,0)
end)
downButton.MouseButton1Click:Connect(function()
myPart.Position += Vector3.new(0,-2,0)
end)
controFrame.Visible = false
myPart.Anchored = true
Apprendre à faire construire un escalier géant de 200 marches par le script lui-même ! Au lieu de placer les marches une par une à la main dans Roblox Studio (bon courage pour 200 marches ! :
Utiliser une Part modèle (template) qui sert de moule.
Cloner cette Part dans une boucle for (rappel du tutoriel de la pyramide !).
Positionner chaque marche avec un calcul basé sur le numéro de la marche (i).
Ranger proprement les marches dans un dossier créé par le script.
Ce tutoriel réunit des briques déjà connues : Clone(), Parent, Instance.new, les boucles for, les Vector3 et les paramètres en variables !
Introduction : Pourquoi générer un escalier par script ?
Imagine que tu veuilles construire un escalier de 200 marches dans Studio :
Chaque marche doit être positionnée un peu plus haut et un peu plus loin que la précédente.
À la main, c’est 200 manipulations… et si tu veux changer la hauteur des marches, il faut tout recommencer !
Avec un script :
Les 200 marches sont créées en moins d’une seconde.
Changer la hauteur, la largeur, le nombre de marches = changer 1 nombre et relancer !
Le niveau reste parfaitement régulier (aucune marche de travers !).
C’est toute la puissance de la programmation : l’ordinateur exécute les tâches répétitives à ta place.
Préparation dans Roblox Studio
Dans Workspace, crée un dossier nommé Folder.
Ajoute une Part nommée Part dans ce dossier : c’est le moule.
Place-la à l’endroit où l’escalier doit commencer.
Sa taille n’a pas grande importance (le script la règle à chaque clone !), mais tu peux lui donner une couleur sympa : toutes les marches hériteront de cette couleur (et du matériau !).
Crée un Script dans ServerScriptService et colle le code.
Lance le jeu :
L’escalier de 200 marches se construit instantanément !
Dans l’Explorer, un dossier EscalierDroit contient Step0, Step1, … Step199.
Teste la montée : ton joueur peut-il grimper ? Si les marches sont trop hautes, réduis rise !
⚠️ Conseil de test : mets stepCount = 10 pendant tes essais (escalier court, facile à inspecter), et seulement à la fin remets 200 !
Générer une échelle
Voici le script simple commenté en français :
-- Récupérer le dossier et la Part modèle (le "moule" des marches)
local stairsFolder = workspace:WaitForChild("Folder")
local template = stairsFolder:WaitForChild("Part")
-- Ancrer le modèle pour qu'il ne tombe pas pendant la construction
template.Anchored = true
template.Size = Vector3.new(8, 0.5, 4)
-- Boucle : créer les marches une par une
-- i va de 1 à 10
for i = 1, 10 do
-- 1) Cloner le modèle
local step = template:Clone()
-- 3) LE CŒUR DU SCRIPT : positionner la marche
-- La marche n° i se trouve à :
-- - X : interval * i → chaque marche est décalée de "interval" studs vers l'avant
-- - Y : (step.Size.Y + hauteur) * i → chaque marche est décalée vers le HAUT
-- - Z : 0 → pas de décalage sur l'axe Z (escalier droit !)
step.Position += Vector3.new(
0, -- décalage horizontal
(step.Size.Y + 1) * i, -- décalage vertical
0 -- pas de décalage Z
)
-- 4) Attacher la marche au dossier et la nommer
step.Parent = stairsFolder
step.Name = "Step" .. i -- "Step0", "Step1", "Step2"...
end
Générer un escalier droit
Voici le script simple commenté en français :
-- Récupérer le dossier et la Part modèle (le "moule" des marches)
local stairsFolder = workspace:WaitForChild("Folder")
local template = stairsFolder:WaitForChild("Part")
-- Ancrer le modèle pour qu'il ne tombe pas pendant la construction
template.Anchored = true
template.Size = Vector3.new(4, 0.5, 8)
-- Boucle : créer les marches une par une
-- i va de 1 à 10
for i = 1, 10 do
-- 1) Cloner le modèle
local step = template:Clone()
-- 3) LE CŒUR DU SCRIPT : positionner la marche
-- La marche n° i se trouve à :
-- - X : interval * i → chaque marche est décalée de "interval" studs vers l'avant
-- - Y : (step.Size.Y + hauteur) * i → chaque marche est décalée vers le HAUT
-- - Z : 0 → pas de décalage sur l'axe Z (escalier droit !)
step.Position += Vector3.new(
3 * i, -- décalage horizontal
(step.Size.Y + 1) * i, -- décalage vertical
0 -- pas de décalage Z
)
-- 4) Attacher la marche au dossier et la nommer
step.Parent = stairsFolder
step.Name = "Step" .. i -- "Step0", "Step1", "Step2"...
end
Et le script modifié pour le même résultat mais avec des constantes pour mieux modifier le code :
-- Récupérer le dossier et la Part modèle (le "moule" des marches)
local stairsFolder = workspace:WaitForChild("Folder")
local template = stairsFolder:WaitForChild("Part")
-- ============================================================
-- PARAMÈTRES : c'est ICI que tout se règle ! Modifie-les !
-- ============================================================
local stepCount = 200 -- nombre de marches
local rise = 2 -- hauteur gagnée par marche (studs)
local stepWidth = 4 -- largeur de la marche (axe X)
local stepDepth = 8 -- profondeur de la marche (axe Z) — espace pour poser le pied
local stepThickness = 0.5 -- épaisseur de la planche de la marche
local interval = 5 -- distance entre deux marches (axe X)
-- Ancrer le modèle pour qu'il ne tombe pas pendant la construction
template.Anchored = true
template.Size = Vector3.new(stepWidth, stepThickness, stepDepth)
-- Boucle : créer les marches une par une
-- i va de 0 à stepCount - 1 (donc 200 tours pour stepCount = 200)
for i = 1, stepCount do
-- 1) Cloner le modèle
local step = template:Clone()
-- 3) LE CŒUR DU SCRIPT : positionner la marche
-- La marche n° i se trouve à :
-- - X : interval * i → chaque marche est décalée de "interval" studs vers l'avant
-- - Y : (step.Size.Y + rise) * i → chaque marche est décalée vers le HAUT
-- - Z : 0 → pas de décalage sur l'axe Z (escalier droit !)
step.Position += Vector3.new(
interval * i, -- décalage horizontal
(step.Size.Y + rise) * i, -- décalage vertical
0 -- pas de décalage Z
)
-- 4) Attacher la marche au dossier et la nommer
step.Parent = stairsFolder
step.Name = "Step" .. i -- "Step0", "Step1", "Step2"...
end
Explications Détaillées
1. La Part modèle (template) : le moule
local template = folder:WaitForChild("Part")
La Part nommée Part dans le dossier Folder ne sera jamais visible dans le jeu : elle sert uniquement de moule.
template:Clone() (rappel du tutoriel de la pyramide inversée !) crée une copie exacte à chaque tour de boucle.
À la fin, template:Destroy() supprime le moule : il a fait son travail ! (La ligne est facultative : sans elle, le moule resterait visible au sol.)
2. Les paramètres : le tableau de bord de l’escalier
local stepCount = 200
local rise = 2
local stepWidth = 4
local stepDepth = 8
local stepThickness = 0.5
local interval = 5
Toutes les dimensions sont des variables en haut du script : c’est le tableau de bord ! Expérimente :
Paramètre
Rôle
Que se passe-t-il si tu le changes ?
stepCount
Nombre de marches
10 → un petit escalier de test ! 500 → une échelle vers le ciel !
rise
Hauteur gagnée par marche
1 → escalier doux. 5 → escalier raide qu’on ne peut pas grimper !
stepWidth
Largeur (axe X)
20 → autoroute géante !
stepDepth
Profondeur (axe Z)
2 → marches minuscules, impossible de monter.
stepThickness
Épaisseur de la planche
2 → marches massives.
interval
Distance entre marches (axe X)
2 → marches serrées, plus facile à monter !
⚠️ Rappel important : pour qu’un joueur puisse monter l’escalier, la hauteur gagnée par marche (rise + stepThickness) doit rester inférieure à la hauteur de marche maximale que Roblox permet de franchir (environ 2.2 studs par défaut). Ici : stepThickness (0.5) + rise (2) = 2.5… c’est juste au-dessus ! Si ton joueur bloque sur une marche, réduis rise à 1.5 ! C’est le genre de chose qu’on découvre… en testant !
3. LA LIGNE MAGIQUE : le calcul de la position
step.Position += Vector3.new(interval * i, (step.Size.Y + rise) * i, 0)
C’est le cœur du tutoriel ! Décomposons-la :
L’axe X : interval * i
Marche n° 0 : décalage = 5 × 0 = 0 → position du moule.
Marche n° 1 : décalage = 5 × 1 = 5 studs.
Marche n° 2 : décalage = 5 × 2 = 10 studs.
Marche n° 199 : décalage = 5 × 199 = 995 studs !
Le numéro de la marche i pilote son décalage : plus i est grand, plus la marche est loin. C’est ça, une progression arithmétique (des maths de collège ! ).
L’axe Y : (step.Size.Y + rise) * i
Chaque marche est posée au-dessus de la précédente.
Pourquoi step.Size.Y + rise et pas juste rise ? L’épaisseur de la planche (stepThickness) doit être ajoutée : sans elle, chaque marche serait enfoncée dans la précédente au lieu d’être posée dessus. Le saut vertical entre deux marches = l’épaisseur de la planche + la hauteur gagnée.
Marche n° 199 : hauteur ≈ 2.5 × 199 = 497.5 studs… un escalier qui monte jusqu’au ciel !
L’axe Z : 0
Aucun décalage : les marches s’alignent droit. C’est pour ça que le dossier s’appelle « EscalierDroit » !
La position de départ : +=
step.Position += ... : on part de la position du moule et on ajoute le décalage. Avantage : l’escalier se construit où que tu aies placé la Part modèle ! Déplace le moule dans Studio → tout l’escalier suit !
4. La boucle et le compteur i
for i = 0, stepCount - 1 do
Pourquoi commencer à 0 et finir à stepCount - 1 ? Avec i = 0, la première marche est positionnée exactement à l’emplacement du moule (décalage × 0 = aucun décalage). C’est plus intuitif pour placer l’escalier !
On pourrait aussi écrire for i = 1, stepCount — mais alors le calcul des positions devrait devenir (i - 1) partout. Choisir 0, c’est éviter les -1 partout !
step.Name = "Step" .. i (rappel : concaténation avec un nombre !) : chaque marche a un nom unique → on pourra la retrouver plus tard avec FindFirstChild("Step" .. i).
QCM : Teste ta compréhension !
Sur le moule (template) :
À quoi sert la Part nommée Part dans le dossier ?
a) C’est la première marche de l’escalier.
b) Elle sert à décorer le niveau.
c) C’est un moule : le script la clone pour créer chaque marche, puis la détruit.
Pourquoi appelle-t-on template:Destroy() à la fin ?
a) Le moule a servi et n’a plus d’utilité : on nettoie le niveau.
b) Pour supprimer tout l’escalier.
c) C’est obligatoire sinon le jeu plante.
Sur les paramètres :
Que se passe-t-il si on change stepCount = 200 en stepCount = 50 ?
a) Les marches sont 4 fois plus petites.
b) L’escalier ne fait plus que 50 marches (il est 4 fois plus court).
c) Rien ne change.
Pourquoi les dimensions sont-elles mises en variables en haut du script ?
a) Parce que Lua oblige.
b) Pour décorer le script.
c) Pour pouvoir ajuster l’escalier facilement en changeant un seul nombre, sans toucher au reste du code.
Sur le calcul de position :
Pour la marche n° 3 (i = 3), quel est son décalage horizontal (axe X) avec interval = 5 ?
a) 15 studs (interval * i = 5 × 3).
b) 5 studs.
c) 3 studs.
Pourquoi ajoute-t-on step.Size.Y dans le calcul vertical (step.Size.Y + rise) * i ?
a) Pour changer la couleur des marches.
b) Pour compenser l’épaisseur de la planche : sinon chaque marche serait enfoncée dans la précédente au lieu d’être posée dessus.
c) Pour aller plus vite.
Pourquoi le décalage Z est-il à 0 ?
a) Parce que l’escalier est DROIT : les marches avancent sur un seul axe, sans tourner.
b) Parce que l’axe Z n’existe pas.
c) Parce que les marches sont invisibles.
Que signifie step.Position += Vector3.new(...) ?
a) On remplace la position par celle du moule.
b) On AJOUTE le décalage à la position actuelle du clone (celle du moule) — l’escalier se construit là où est placé le moule.
c) On supprime la position. Réponse : a)
Sur la boucle :
Pourquoi la boucle va-t-elle de 0 à stepCount - 1 ?
a) Pour que la première marche (i = 0) soit positionnée exactement à l’emplacement du moule, sans décalage.
b) Parce que Lua interdit de commencer à 1.
c) Pour économiser une marche.
Pourquoi chaque marche reçoit-elle un nom "Step" .. i ?
a) Pour avoir un nom unique et pouvoir retrouver chaque marche plus tard (FindFirstChild).
b) Pour la détruire immédiatement.
c) Pour changer sa couleur.
Pourquoi range-t-on toutes les marches dans le dossier EscalierDroit ?
a) Pour garder l’Explorer propre et pouvoir manipuler tout l’escalier d’un seul coup.
b) Parce que les Parts ne peuvent pas être ailleurs.
c) Pour rien.
Idées pour aller plus loin
1. Un escalier aux couleurs dégradées
Objectif : dégradé du bleu au rouge du bas en haut (rappel des boucles de couleurs du tutoriel de découverte !).
Comment faire ? Calcule la couleur avec i :
-- Dans la boucle : local ratio = i / stepCount -- de 0 à 1 step.Color = Color3.fromRGB(255 * ratio, 0, 255 * (1 - ratio))
2. Une marche sur deux avec un effet spécial
Objectif : les marches paires sont normales, les marches impaires personnalisées !
Comment faire ? Utilise l’opérateur modulo (i % 2) qui donne le reste de la division :
if i % 2 == 1 then -- marches impaires step.Color = Color3.fromRGB(0, 255, 0) step.BrickColor = BrickColor.new("Lime green") end
3. Un escalier construit marche par marche (effet visuel)
Objectif : voir l’escalier se construire sous tes yeux.
Comment faire ? Ajoute task.wait(0.05) à la fin de la boucle : chaque marche apparaît l’une après l’autre ! Pour 200 marches, ça donne un bel effet de construction.
4. Démontage progressif de l’escalier
Objectif : comme pour la pyramide, détruire les marches une par une (mode « escalier qui s’effondre » !).
Comment faire ? (rappel du tutoriel de découverte !)
task.wait(3) for i = stepCount - 1, 0, -1 do stairsFolder:FindFirstChild("Step" .. i):Destroy() task.wait(0.1) end
for i = stepCount - 1, 0, -1 do stairsFolder:FindFirstChild("Step" .. i):Destroy()task.wait(0.1)end
Résumé des concepts clés
Concept
Description
Exemple
Part modèle (template)
Un moule cloné autant de fois que nécessaire, puis détruit.
template:Clone()
Paramètres en variables
Toutes les dimensions réglables en haut du script.
stepCount, rise, interval
Boucle for génératrice
Le compteur i pilote à la fois le nombre ET la position.
for i = 0, stepCount - 1 do
Progression arithmétique
Décalage = valeur × numéro de la marche.
interval * i
Vector3.new de position
Décalage X (avant), Y (haut), Z (côté).
‘+= Vector3.new(interval*i, hauteur*i, 0)
Instance.new("Folder")
Créer un dossier par le script pour ranger les clones.
.Parent = workspace
Noms uniques
Retrouver chaque clone plus tard.
"Step" .. i
Ne pas masquer les variables
Deux variables du même nom = confusion !
folder vs stairsFolder
Astuce : teste toujours avec stepCount = 10 avant de passer à 200 — un escalier court, c’est plus rapide à inspecter, et tu gagneras un temps fou pendant tes réglages
Génère ton escalier par une fonction paramètrable
-- Récupérer le dossier et la Part modèle (le "moule" des marches)
local stairsFolder = workspace:WaitForChild("Folder")
local template = stairsFolder:WaitForChild("Part")
-- ============================================================
-- PARAMÈTRES : c'est ICI que tout se règle ! Modifie-les !
-- ============================================================
local function createStairs(base, stepCount, rise, interval, stepWidth, stepDepth, stepThickness)
-- 2) Vérifier que le modèle a une base pour se coller
if not base:IsA("BasePart") then
warn("❌ Le modèle doit être une Part pour être cloné !")
return
end
if not stepCount then stepCount = 12 end
if not rise then rise = 2 end -- hauteur entre chaque marche
if not interval then interval = 5 end -- décalage entre chaque marche
if not stepWidth then stepWidth = base.Size.X end
if not stepDepth then stepDepth = base.Size.Z end
if not stepThickness then stepThickness = base.Size.Y end
-- Ancrer le modèle pour qu'il ne tombe pas pendant la construction
template.Anchored = true
template.Size = Vector3.new(stepWidth, stepThickness, stepDepth)
-- Boucle : créer les marches une par une
-- i va de 0 à stepCount - 1 (donc 200 tours pour stepCount = 200)
for i = 1, stepCount do
-- 1) Cloner le modèle
local step = template:Clone()
-- 3) LE CŒUR DU SCRIPT : positionner la marche
-- La marche n° i se trouve à :
-- - X : interval * i → chaque marche est décalée de "interval" studs vers l'avant
-- - Y : (step.Size.Y + rise) * i → chaque marche est décalée vers le HAUT
-- - Z : 0 → pas de décalage sur l'axe Z (escalier droit !)
step.Position += Vector3.new(
interval * i, -- décalage horizontal
(step.Size.Y + rise) * i, -- décalage vertical
0 -- pas de décalage Z
)
-- 4) Attacher la marche au dossier et la nommer
step.Parent = stairsFolder
step.Name = "Step" .. i -- "Step0", "Step1", "Step2"...
end
end
createStairs(template)
Dans tous les sens :
-- Récupérer le dossier et la Part modèle (le "moule" des marches)
local stairsFolder = workspace:WaitForChild("Escalier")
local template = stairsFolder:WaitForChild("Marche")
-- ============================================================
-- PARAMÈTRES : c'est ICI que tout se règle ! Modifie-les !
-- ============================================================
local function createStairs(base, stepCount, rise, name, intervalX, intervalZ, stepWidth, stepDepth, stepThickness)
-- 2) Vérifier que le modèle a une base pour se coller
if not base:IsA("BasePart") then
warn("❌ Le modèle doit être une Part pour être cloné !")
return
end
if not stepCount then stepCount = 12 end
if not name then name = "Step" end -- Par defaut "Step"
if not rise then rise = 2 end -- hauteur entre chaque marche
if not intervalX then intervalX = 5 end -- décalage entre chaque marche
if not intervalZ then intervalZ = 0 end -- décalage entre chaque marche
if not stepWidth then stepWidth = base.Size.X end
if not stepDepth then stepDepth = base.Size.Z end
if not stepThickness then stepThickness = base.Size.Y end
-- Ancrer le modèle pour qu'il ne tombe pas pendant la construction
template.Anchored = true
template.Size = Vector3.new(stepWidth, stepThickness, stepDepth)
-- Boucle : créer les marches une par une
-- i va de 0 à stepCount - 1 (donc 200 tours pour stepCount = 200)
for i = 1, stepCount do
-- 1) Cloner le modèle
local step = template:Clone()
-- 3) LE CŒUR DU SCRIPT : positionner la marche
-- La marche n° i se trouve à :
-- - X : interval * i → chaque marche est décalée de "interval" studs vers l'avant
-- - Y : (step.Size.Y + rise) * i → chaque marche est décalée vers le HAUT
-- - Z : 0 → pas de décalage sur l'axe Z (escalier droit !)
step.Position += Vector3.new(
intervalX, -- décalage horizontal
(step.Size.Y + rise), -- décalage vertical
intervalZ -- pas de décalage Z
) * i
-- 4) Attacher la marche au dossier et la nommer
step.Parent = stairsFolder
step.Name = name .. i -- "Step0", "Step1", "Step2"...
end
end
createStairs(template, 20, 1 , "StairA", 5, 0)
createStairs(template, 20, 1 , "StairB", -5, 0)
createStairs(template, 20, 1 , "StairC", 0, -5)
createStairs(template, 20, 1 , "StairD", 0, 5)
createStairs(template, 20, 1 , "StairE", 5, 5)
createStairs(template, 20, 1 , "StairF", -5, 5)
createStairs(template, 20, 1 , "StairG", 5, -5)
createStairs(template, 20, 1 , "StairH", -5, -5)
Générer un escalier hélicoïdal ou en colimaçon
local folder = workspace:WaitForChild("Folder")
local template = folder:WaitForChild("Part")
-- Paramètres
local stepCount = 400 -- nombre de marches
local angleStep = 15 -- degrés entre deux marches (360 / 15 = 24 marches par tour)
local rise = 1 -- hauteur gagnée par marche (studs)
local radius = 12 -- distance entre l'axe de l'escalier et le centre d'une marche
local stepWidth = 8 -- largeur radiale de la marche
local stepThickness = 0.5 -- épaisseur de la marche
local center = template.Position
-- Profondeur de la marche : corde de l'arc extérieur, pour éviter les trous entre deux marches
local outerRadius = radius + stepWidth / 2
local stepDepth = 2 * outerRadius * math.sin(math.rad(angleStep) / 2)
template.Anchored = true
local stairsFolder = Instance.new("Folder")
stairsFolder.Name = "EscalierHelicoidal"
stairsFolder.Parent = workspace
for i = 0, stepCount - 1 do
local step = template:Clone()
step.Size = Vector3.new(stepWidth, stepThickness, stepDepth)
step.Anchored = true
local ratio = i / stepCount -- de 0 à 1
step.Color = Color3.fromRGB(255 * ratio, 0, 255 * (1 - ratio))
-- 1) on se place à la bonne hauteur, 2) on tourne autour de l'axe Y,
-- 3) on s'écarte du centre : la marche est ainsi orientée radialement
step.CFrame = CFrame.new(center + Vector3.new(0, i * rise, 0))
* CFrame.Angles(0, math.rad(i * angleStep), 0)
* CFrame.new(radius, 0, 0)
step.Parent = stairsFolder
end
template:Destroy() -- retire la part modèle (facultatif)
Découvrir la vie d’un joueur dans Roblox avec un script « d’observation » :
Quand un joueur se connecte (PlayerAdded).
Quand son personnage apparaît (CharacterAdded).
Quand son personnage change d’état (StateChanged : saut, chute, atterrissage…).
Ce script n’est pas un jeu : c’est un script espion ! Il ne fait qu’afficher dans la console (Output) tout ce qui arrive aux joueurs. C’est l’outil parfait pour comprendre ces trois événements avant de les utiliser dans de vrais jeux (comme la zone anti-gravité ou les checkpoints !).
Introduction : La vie d’un joueur Roblox
Quand tu joues à un jeu Roblox, voici ce qui se passe dans l’ordre :
1. Le JOUEUR se connecte au jeu → événement PlayerAdded
2. Son PERSONNAGE apparaît dans le monde → événement CharacterAdded
3. Le personnage agit : court, saute, tombe, atterrit...
→ événements StateChanged (très fréquents !)
4. Le personnage meurt → il est détruit
5. Un NOUVEAU personnage apparaît → de nouveau CharacterAdded !
6. Le joueur quitte le jeu → événement PlayerRemoving
Les trois personnages-clés de ce tuto :
player : le compte du joueur (son nom, son UserId…). Il ne meurt jamais.
character : le bonhomme 3D dans le monde (le Model). Il est détruit à chaque mort et recréé à chaque respawn !
humanoid : l’ordinateur de bord du personnage : il gère la santé, la marche, les sauts… et ses états.
Les états d’un Humanoid (Enum.HumanoidStateType)
État
Signification
Running
Le personnage court au sol.
Jumping
Il vient de sauter.
Freefall
Il tombe dans les airs.
Landed
Il vient d’atterrir.
Climbing
Il grimpe à une échelle.
Swimming
Il nage.
Seated
Il est assis (dans un siège).
Dead
Il est mort !
PlatformStanding / Physics
États spéciaux (plateformes, physique).
C’est la même liste que celle utilisée dans le tutoriel de la zone anti-gravité (Jumping, Freefall, Landed) !
Voici le script commenté en français :
-- Récupérer le service des joueurs
local Players = game:GetService("Players")
-- Fonction appelée à chaque changement d'état du Humanoid
-- Paramètres : le joueur, l'ancien état, le nouvel état
local function onStateChanged(player, oldState, newState)
-- Affiche dans la console : nom du joueur, ancien état -> nouvel état
print(player.Name, "État changé :", oldState, "->", newState)
end
-- Fonction appelée à chaque apparition du personnage
local function onCharacterAdded(player, character)
-- Attendre que le Humanoid existe (le personnage se construit progressivement !)
local humanoid = character:WaitForChild("Humanoid")
-- Connecter le suivi des changements d'états de CE personnage
humanoid.StateChanged:Connect(function(oldState, newState)
onStateChanged(player, oldState, newState)
end)
end
-- Fonction appelée à chaque connexion d'un joueur
local function onPlayerAdded(player)
print(player.Name .. " est connecté")
-- Cas 1 : le personnage existe DÉJÀ (joueur connecté avant le script)
if player.Character then
task.spawn(onCharacterAdded, player, player.Character)
end
-- Cas 2 : chaque apparition/réapparition FUTURE (mort, respawn...)
player.CharacterAdded:Connect(function(character)
onCharacterAdded(player, player.Character and character)
end)
end
-- Nouveaux joueurs : événement pour chaque joueur qui se connecte
Players.PlayerAdded:Connect(onPlayerAdded)
-- Joueurs déjà connectés AVANT l'exécution du script
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player)
end
Explications Détaillées : les 4 fonctions du script
Le script est construit comme des poupées russes : chaque fonction appelle la suivante !
local function onStateChanged(player, oldState, newState)
print(player.Name, "État changé :", oldState, "->", newState)
end
oldState : l’état avant le changement (ex: Enum.HumanoidStateType.Freefall).
newState : le nouvel état (ex: Enum.HumanoidStateType.Landed).
Rappel des tutoriels précédents : print(...) avec plusieurs valeurs séparées par des virgules affiche tout sur une ligne (pas besoin de concaténer avec .. !).
2. onCharacterAdded : l’espion des personnages
local function onCharacterAdded(player, character)
local humanoid = character:WaitForChild("Humanoid")
humanoid.StateChanged:Connect(function(oldState, newState)
onStateChanged(player, oldState, newState)
end)
end
WaitForChild("Humanoid") : quand le personnage apparaît, il se construit pièce par pièce (jambes, torse, tête, Humanoid…). Si on cherche le Humanoidtrop tôt, il n’existe pas encore → erreur ! WaitForChild attend qu’il soit prêt. (Rappel : c’est pareil que WaitForChild("HumanoidRootPart") dans le tutoriel des checkpoints !)
On connecte StateChanged pour ce personnage précis. Quand il mourra, ce personnage sera détruit — et sa connexion avec lui ! C’est pour ça qu’il faut reconnecter StateChanged à chaque nouveau personnage (d’où la fonction !).
3. onPlayerAdded : l’espion des joueurs (avec ses DEUX pièges !)
local function onPlayerAdded(player)
print(player.Name .. " est connecté")
-- Cas 1 : le personnage existe DÉJÀ
if player.Character then
task.spawn(onCharacterAdded, player, player.Character)
end
-- Cas 2 : apparitions futures
player.CharacterAdded:Connect(function(character)
onCharacterAdded(player, character)
end)
end
Piège n°1 : le joueur est arrivé AVANT le script !
Le problème : PlayerAdded ne se déclenche que pour les joueurs qui arrivent après la connexion de l’événement. Si tu es déjà dans le jeu quand le script démarre (par exemple au lancement d’un test Studio), l’événement t’a raté !
La solution en deux temps :
Cas 1 : if player.Character then → le personnage existe déjà → on appelle directementonCharacterAdded.
Cas 2 : player.CharacterAdded:Connect(...) → on guette toutes les apparitions futures (respawn après mort !).
Piège n°2 : CharacterAdded ne se déclenche pas à la connexion… mais un peu après !
Quand un joueur se connecte, PlayerAdded se déclenche, puis Roblox crée son personnage (quelques millisecondes plus tard) → c’est ensuite que CharacterAdded se déclenche. C’est pour ça que le Cas 2 attrape aussi la toute première apparition !
Pourquoi task.spawn ?
task.spawn(fonction, arguments...) lance la fonction dans un thread séparé : le script n’attend pas qu’elle soit finie pour continuer.
Indispensable ici : onCharacterAdded commence par un WaitForChild… qui peut bloquer quelques instants. Sans task.spawn, un joueur « lent à charger » bloquerait le traitement des autres joueurs !
4. La boucle finale : rattraper tout le monde
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player)
end
Players.PlayerAdded:Connect(onPlayerAdded) : tous les joueurs qui arriveront à partir de maintenant.
Players:GetPlayers() : la liste des joueurs déjà présents (rappel : c’est une liste numérotée, donc on utilise ipairs, comme pour les checkpoints et les blocs de la pyramide !).
La boucle appelle onPlayerAdded pour chacun : personne n’est oublié, même si le script a démarré en retard !
Le schéma complet :
Préparation et Test dans Roblox Studio
Crée un Script dans ServerScriptService et colle le code corrigé.
Ouvre la fenêtre Output (onglet View → Output) : c’est là que les print s’affichent.
Lance le jeu en mode multi-joueurs (onglet Test → 2 Players) pour observer plusieurs joueurs !
Expérimente et observe la console :
Action
Ce que tu dois voir dans l’Output
Le jeu démarre
patrick est connecté (et celui du 2ème joueur !)
Tu marches
Rien (rester en Running ne change pas l’état !)
Tu sautes
Jumping puis Freefall
Tu atterris
Landed puis Running
Tu montes une échelle
Climbing
Tu tombes dans l’eau
Swimming
Tu t’assois sur un siège
Seated
Tu te reset (menu Echap → Reset)
Dead… puis re-connexion automatique de CharacterAdded !
Contrôle important : après ta mort, observe bien : onCharacterAdded est rappelé automatiquement pour ton nouveau personnage (le script continue d’afficher tes états, sans rien refaire !). C’est la preuve que le Cas 2 de onPlayerAdded fonctionne.
QCM : Teste ta compréhension !
Sur la vie du joueur :
Dans quel ordre se passent les événements quand un joueur rejoint le jeu ?
a) PlayerAdded → CharacterAdded → puis les StateChanged.
b) CharacterAdded → PlayerAdded → StateChanged.
c) Tout en même temps.
Quelle est la différence entre player et character ?
a) player est le compte (ne meurt jamais) ; character est le bonhomme 3D, détruit à chaque mort et recréé à chaque respawn.
b) Ce sont deux noms pour la même chose.
c) character est le compte du joueur.
Que se passe-t-il pour la connexion StateChanged quand le personnage meurt et respawn ?
a) Elle est détruite avec l’ancien personnage ; onCharacterAdded reconnecte StateChanged sur le NOUVEAU Humanoid.
b) Elle reste active sur l’ancien personnage.
c) Roblox la reconnecte automatiquement tout seul.
Sur les états du Humanoid :
Dans quel ordre les états s’enchaînent-ils quand tu sautes d’une plateforme ?
a) Jumping → Freefall → Landed → Running.
b) Running → Landed → Jumping.
c) Freefall → Jumping → Running.
Pourquoi ne voit-on rien s’afficher quand on marche normalement pendant longtemps ?
a) Parce qu’on reste dans l’état Running : sans changement d’état, StateChanged ne se déclenche pas.
b) Parce que print est cassé.
c) Parce que la marche est trop rapide.
Quels sont les paramètres reçus par la fonction connectée à StateChanged ?
a) L’ancien état et le nouvel état (oldState, newState).
b) Le joueur et le personnage.
c) Rien.
Sur les deux pièges :
Pourquoi le script a-t-il besoin de la boucle for ... ipairs(Players:GetPlayers()) à la fin ?
a) Pour rattraper les joueurs déjà connectés AVANT le démarrage du script (l’événement PlayerAdded ne se déclenche pas pour eux).
a) Parce que le personnage se construit pièce par pièce : au moment de l’événement, le Humanoid n’existe peut-être pas encore.
b) Pour faire joli.
c) Parce que le Humanoid n’existe jamais.
Dans onPlayerAdded, à quoi sert le if player.Character then ?
a) À vérifier si le personnage existe DÉJÀ (le joueur était là avant le script) pour ne pas rater son suivi.
b) À créer un personnage.
c) À détruire le personnage.
Résumé des concepts clés
Concept
Description
Exemple
PlayerAdded
Un joueur se connecte au jeu.
Players.PlayerAdded:Connect(onPlayerAdded)
CharacterAdded
Un personnage apparaît (1ère fois OU respawn !).
player.CharacterAdded:Connect(...)
StateChanged
Le Humanoid change d’état (saut, chute, atterrissage…).
humanoid.StateChanged:Connect(...)
Enum.HumanoidStateType
La liste des états possibles.
Landed, Jumping, Freefall, Dead…
GetPlayers()
Liste des joueurs déjà présents (rattrapage !).
for _, player in ipairs(...) do
task.spawn
Lance une fonction sans bloquer le script.
task.spawn(onPlayerAdded, player)
WaitForChild("Humanoid")
Attend que le personnage soit prêt.
Anti-erreur !
GetState()
Lit l’état ACTUEL (sans attendre un changement).
Utilisé dans la zone anti-gravité
Astuce : Garde ce script espion dans un coin de tes projets : dès qu’un comportement te semble mystérieux (pourquoi mon joueur ne saute plus ?), les print des états te diront exactement ce qui se passe dans la tête du Humanoid ! C’est l’outil de débogage parfait
Construire en trois phases une zone magique où le joueur flotte comme dans l’espace :
Phase 1 : donner au joueur une force vers le haut pour annuler la gravité quand il touche une Part.
Phase 2 : supprimer cette force quand le joueur sort de la Part (et qu’il est retombé au sol).
Phase 3 : supprimer cette force quand le joueur touche le sol (atterrissage).
Ce tutoriel introduit des concepts physiques réels (masse, gravité, poids) et des outils Roblox puissants : VectorForce, Attachment, les états de l’Humanoid (StateChanged) et GetTouchingParts().
Introduction : La physique de la gravité dans Roblox
Pourquoi le joueur tombe-t-il ?
Dans Roblox (comme dans la vraie vie !), chaque objet subit son poids : une force qui le tire vers le bas.
Poids = Masse × Gravité
La gravité de Roblox vaut par défaut 196.2 studs/seconde² (accessible via workspace.Gravity).
La masse du personnage est accessible via rootPart.AssemblyMass.
Donc le poids d’un personnage = 196.2 × masse, dirigé vers le bas (Vector3.new(0, -poids, 0)).
L’astuce du script : annuler le poids !
Si on applique sur le joueur une force exactement égale à son poids, mais vers le HAUT, les deux forces s’annulent :
Gravité (vers le bas) + Notre force (vers le haut) = 0 → le joueur FLOTE !
C’est exactement ce que ressentent les astronautes en orbite : ils sont en apesanteur !
L’outil : VectorForce
Un VectorForce est un objet qui applique une force en continue sur une Part. Il a besoin :
d’un Attachment (un point d’ancrage invisible sur la Part),
d’une direction relative au monde (Enum.ActuatorRelativeTo.World),
d’une force exprimée en Vector3.
Préparation dans Roblox Studio
Dans Workspace, crée un dossier GravityFolder.
Ajoute une Part nommée Part : c’est la zone d’apesanteur.
Forme libre : une grande zone transparente (Transparency = 0.8, Anchored = true, CanCollide = false) donne le meilleur effet « champ de force » !
Important : laisse CanTouch = true (sinon Touched, TouchEnded et GetTouchingParts ne fonctionnent pas !).
Crée un Script dans ServerScriptService et colle le code complet (utilitaires + Phase 1 + Phase 2 + Phase 3).
Le script completcommenté en français
Voici le script commenté en français :
-- Récupérer le service des joueurs
local Players = game:GetService("Players")
-- Récupérer la zone anti-gravité
local folder = workspace:WaitForChild("GravityFolder")
local antiGravityPart = folder:WaitForChild("Part")
-- Constantes du script
local GRAVITY = workspace.Gravity * 0.99 -- 99% de la gravité (petite marge de confort)
local DEFAULT_JUMP_HEIGHT = 7.2 -- Hauteur de saut normale de Roblox
-- Fonction utilitaire : retrouve le personnage, son rootPart et son humanoid
-- à partir de l'objet qui a touché la zone (hit)
local function getCharacterFromHit(hit)
local character = hit:FindFirstAncestorOfClass("Model")
if not character then return nil end
local rootPart = character:FindFirstChild("HumanoidRootPart")
if not rootPart then return nil end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if not humanoid then return nil end
return character, rootPart, humanoid -- On retourne les trois !
end
-- Fonction utilitaire : crée (ou retrouve) la force anti-gravité du joueur
local function getOrCreateAntiGravityForce(rootPart)
-- Chercher une force déjà existante (pour ne pas en créer deux !)
local force = rootPart:FindFirstChildOfClass("VectorForce")
if not force then
-- Créer le point d'ancrage invisible sur le joueur
local attachment = Instance.new("Attachment")
attachment.Parent = rootPart
-- Créer la force vectorielle
force = Instance.new("VectorForce")
force.Attachment0 = attachment -- où la force s'applique
force.RelativeTo = Enum.ActuatorRelativeTo.World -- relative au MONDE
force.ApplyAtCenterOfMass = true -- au centre de masse
force.Parent = rootPart
end
return force
end
-- Fonction utilitaire : supprime la force anti-gravité du joueur
local function removeAntiGravityForce(rootPart, humanoid)
local force = rootPart:FindFirstChildOfClass("VectorForce")
if force then
force.Attachment0:Destroy() -- supprimer le point d'ancrage
force:Destroy() -- supprimer la force
humanoid.JumpHeight = DEFAULT_JUMP_HEIGHT -- restaurer le saut normal
end
end
-- Fonction utilitaire : vérifie si le personnage touche encore la zone
local function isStillTouching(character)
for _, touchingPart in pairs(antiGravityPart:GetTouchingParts()) do
if touchingPart.Parent == character then
return true -- une partie du corps touche encore la zone !
end
end
return false -- plus aucun contact
end
PHASE 1 : Donner une force vers le haut quand le joueur touche la Part
But : dès que le joueur entre dans la zone, il flotte.
Explication :
L’événement Touched se déclenche à chaque contact (rappel : des dizaines de fois par seconde !).
On cherche si une VectorForce existe déjà sur le joueur (sinon on en crée une). C’est notre réflexe « cooldown » des tutoriels précédents : ne jamais créer deux fois le même objet !
On applique la formule physique : Force = Masse × Gravité, vers le haut.
On réduit JumpHeight à 1 : en apesanteur, un grand saut propulserait le joueur au plafond !
-- PHASE 1 : le joueur touche la zone → il flotte
antiGravityPart.Touched:Connect(function(hit)
-- Retrouver personnage, rootPart et humanoid (fonction utilitaire)
local character, rootPart, humanoid = getCharacterFromHit(hit)
if not character then return end -- pas un joueur : on ignore
-- Créer la force (ou retrouver celle qui existe déjà)
local force = getOrCreateAntiGravityForce(rootPart)
-- LA PHYSIQUE : force qui annule exactement le poids, vers le haut
-- Force = gravité × masse, dirigée vers +Y
force.Force = Vector3.new(0, GRAVITY * rootPart.AssemblyMass, 0)
-- En apesanteur, on limite le saut pour ne pas partir dans le décor !
humanoid.JumpHeight = 1
end)
Points clés :
rootPart.AssemblyMass : la masse totale du personnage (corps, habits, accessoires…). C’est pour ça qu’on utilise AssemblyMass et pas Mass (qui ne donnerait que la masse d’une seule Part !).
GRAVITY = workspace.Gravity * 0.99 : pourquoi 99 % et pas 100 % ? Avec exactement 100 %, le joueur resterait figé en l’air comme une statue. Avec 99 %, il descend très lentement : il peut quitter la zone en marchant, c’est plus jouable ! (Tu peux essayer 1.0 pour voir la différence.)
force.Force = Vector3.new(0, GRAVITY * rootPart.AssemblyMass, 0) : la force est recalculée à chaque Touched, donc si le joueur met un chapeau (masse change), la force s’adapte !
Teste ! Place une Part dans un dossier GravityFolder, lance le jeu et marche dedans. Contrôle :
Ton personnage ** monte lentement / flotte ** dans la zone.
Dans l’Explorer, tu vois apparaître un VectorForce et un Attachment dans le HumanoidRootPart.
Si tu sautes, tu ne montes presque plus (JumpHeight = 1).
QCM :
Que vaut la force appliquée au joueur ?
a) La masse du personnage multipliée par la gravité, dirigée vers le haut.
b) Toujours 100, vers le bas.
c) La taille du personnage.
Pourquoi utilise-t-on rootPart.AssemblyMass plutôt que rootPart.Mass ?
a) Pour avoir la masse totale du personnage entier, pas seulement celle du HumanoidRootPart.
b) C’est plus rapide.
c) Mass n’existe pas.
Que se passe-t-il si on ne vérifie pas si la force existe déjà (if not force then)?
a) Des dizaines de VectorForce seraient créées (un par contact Touched !) et le joueur partirait en fusée.
b) Rien.
c) Le jeu plante immédiatement.
Pourquoi mettre humanoid.JumpHeight = 1 ?
a) Pour empêcher le joueur de sauter très haut dans la zone et s’échapper au plafond.
b) Pour l’empêcher de marcher.
c) Pour changer sa couleur.
PHASE 2 : Supprimer la force quand le joueur SORT de la Part
But : quand le joueur n’a plus aucun contact avec la zone, il retrouve une gravité normale.
Explication — le problème est plus subtil qu’il n’y paraît :
L’événement TouchEnded se déclenche quand une Part précise (ex: le pied gauche) quitte le contact. Mais le joueur a encore peut-être un autre contact (le pied droit !).
Il faut donc vérifier avec GetTouchingParts() : la liste de toutes les Parts qui touchent actuellement la zone. Si une seule appartient au personnage → il est encore dedans → on ne fait rien !
Autre piège : si le joueur saute ou est en chute libre au moment du TouchEnded, il est en l’air et va retoucher la zone dans un instant. On regarde donc son état avec humanoid:GetState() :
Enum.HumanoidStateType.Jumping : il saute → on attend.
Enum.HumanoidStateType.Freefall : il tombe → on attend.
Dernier piège : GetTouchingParts() est appelé après un task.wait(1) pour laisser le temps au moteur physique de « réaliser » le nouveau contact (ex: le joueur qui retombe dans la zone juste après le saut).
-- PHASE 2 : le joueur sort de la zone → on enlève la force
antiGravityPart.TouchEnded:Connect(function(hit)
-- Retrouver le personnage
local character, rootPart, humanoid = getCharacterFromHit(hit)
if not character then return end
-- S'il saute ou tombe, il va peut-être retoucher la zone : on ne fait rien !
local state = humanoid:GetState()
if state == Enum.HumanoidStateType.Jumping then return end
if state == Enum.HumanoidStateType.Freefall then return end
-- Attendre 1 seconde que le contact se stabilise
task.wait(1)
-- Le joueur touche-t-il ENCORE la zone (avec une autre partie du corps) ?
if isStillTouching(character) then return end -- oui : il est toujours dedans !
-- Non : il est bien sorti → supprimer la force
removeAntiGravityForce(rootPart, humanoid)
end)
Teste ! Entre dans la zone, flotte, puis sors complètement. Contrôle :
Tant qu’une seule partie de ton corps touche la zone, tu continues de flotter (demande à un camarade de regarder depuis l’extérieur !).
Dès que tu es totalement sorti, tu retombes : gravité normale, saut restauré (JumpHeight = 7.2).
Le VectorForce et l’Attachment ont disparu de l’Explorer.
QCM :
Pourquoi vérifier isStillTouching(character) avant de supprimer la force ?
a) Parce que TouchEnded se déclenche pour UNE Part (ex: un pied), mais le joueur peut encore toucher la zone avec une autre (ex: l’autre pied).
b) Pour faire durer le jeu.
c) Parce que TouchEnded ne fonctionne pas.
Que retourne part:GetTouchingParts() ?
a) La liste de toutes les Parts actuellement en contact avec la zone.
b) Le nom de la zone.
c) La liste des joueurs du serveur.
Pourquoi ignorer l’événement si l’état du joueur est Jumping ou Freefall ?
a) Parce qu’en sautant ou en tombant, il va probablement retoucher la zone juste après : ce serait bête de couper la force pour rien.
b) Parce que les sauts sont interdits.
c) Parce que l’état Jumping détruit la force tout seul.
Que fait removeAntiGravityForce(rootPart, humanoid) ?
a) Détruit l’Attachment et le VectorForce, et restaure JumpHeight à 7.2.
b) Détruit le personnage.
c) Téléporte le joueur.
PHASE 3 : Supprimer la force quand le joueur TOUCCHE LE SOL
But : si le joueur saute hors de la zone et atterrit au sol (sans jamais déclencher le TouchEnded complet de la Phase 2), il faut quand même couper la force — sinon il flotterait en permanence !
Explication — un nouvel outil puissant : StateChanged !
Un Humanoid passe par des états : Running (il court), Jumping (il saute), Freefall (il tombe), Landed (il vient d’atterrir !)…
L’événement humanoid.StateChanged se déclenche à chaque changement d’état, avec l’ancien et le nouvel état en paramètres.
Notre règle : si le nouvel état est Landed ET que le joueur ne touche plus la zone → on supprime la force.
Le problème du respawn : le VectorForce est attaché au personnage. Si on connectait StateChanged n’importe quand, on raterait les joueurs déjà connectés ou ceux qui respawnent. Solution en 3 étages (déjà vue dans le tutoriel des checkpoints !) :
Players.PlayerAdded → nouveau joueur.
player.CharacterAdded → chaque apparition/réapparition du personnage.
humanoid.StateChanged → chaque changement d’état.
-- PHASE 3 : le joueur atterrit au sol → on enlève la force
-- Fonction appelée à chaque changement d'état du Humanoid
local function onStateChanged(character, humanoid, oldState, newState)
-- On ne s'intéresse qu'à l'atterrissage
if newState ~= Enum.HumanoidStateType.Landed then return end
local rootPart = character:FindFirstChild("HumanoidRootPart")
if not rootPart then return end
-- S'il touche encore la zone, il est dedans : on garde la force !
if isStillTouching(character) then return end
-- Il a atterri ailleurs : supprimer la force
removeAntiGravityForce(rootPart, humanoid)
end
-- Fonction appelée à chaque apparition du personnage
local function onCharacterAdded(player, character)
-- Attendre que le Humanoid existe
local humanoid = character:WaitForChild("Humanoid")
-- Connecter le suivi des changements d'état
humanoid.StateChanged:Connect(function(oldState, newState)
onStateChanged(character, humanoid, oldState, newState)
end)
end
-- Fonction appelée à chaque connexion d'un joueur
local function onPlayerAdded(player)
-- Cas 1 : le personnage existe DÉJÀ (joueur connecté avant le script)
if player.Character then
task.spawn(onCharacterAdded, player, player.Character)
end
-- Cas 2 : chaque apparition/réapparition future (mort, respawn...)
player.CharacterAdded:Connect(function(character)
onCharacterAdded(player, character)
end)
end
-- Nouveaux joueurs
Players.PlayerAdded:Connect(onPlayerAdded)
-- Joueurs déjà connectés AVANT l'exécution du script
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player)
end
Points clés :
newState ~= Enum.HumanoidStateType.Landed : on filtre tout ce qui n’est pas un atterrissage (courir, sauter, nager…). Seul Landed nous intéresse.
Le duo PlayerAdded + boucle sur GetPlayers() : rappelle-toi le tutoriel des checkpoints ! Si le script démarre après qu’un joueur soit déjà là, PlayerAdded ne se déclenchera pas pour lui → la boucle finale rattrape tout le monde.
task.spawn(...) : lance la fonction dans un thread séparé pour ne pas bloquer le script pendant les WaitForChild.
Teste ! Entre dans la zone, saute au-dessus des bords et atterris loin de la zone. Contrôle :
À l’atterrissage hors zone, tu retombes immédiatement sous la gravité normale : le VectorForce a été détruit par la Phase 3 (et non la Phase 2 !).
Tue ton personnage (Reset Character dans le menu) : il respawn… sans force résiduelle (le VectorForce était attaché à l’ancien personnage, détruit avec lui — et le nouveau personnage est suivi par CharacterAdded !).
QCM :
À quoi sert humanoid.StateChanged ?
a) À exécuter une fonction à chaque changement d’état du personnage (saut, chute, atterrissage…).
b) À détecter les collisions.
c) À changer la couleur du personnage.
Quel état nous intéresse dans la Phase 3 ?
a) Enum.HumanoidStateType.Landed (le joueur vient d’atterrir).
b) Enum.HumanoidStateType.Running.
c) Enum.HumanoidStateType.Dead.
Pourquoi le script se connecte-t-il aussi à player.CharacterAdded ?
a) Pour suivre chaque nouveau personnage (mort, respawn) et reconnecter StateChanged à chaque fois.
b) Pour créer un nouveau joueur.
c) Pour rien.
Pourquoi une boucle for sur Players:GetPlayers() à la fin du script ?
a) Pour rattraper les joueurs déjà connectés avant le démarrage du script (le PlayerAdded ne se déclenche pas pour eux).
b) Pour les déconnecter.
c) Pour afficher leurs noms.
Pourquoi vérifier isStillTouching(character) même dans la Phase 3 ?
a) Parce qu’un joueur peut atterrir SUR la zone elle-même (ou juste à côté, en la touchant encore) : il doit continuer à flotter !
b) Pour compter les joueurs.
c) C’est inutile.
Résumé des concepts clés
Concept
Description
Exemple
Poids = masse × gravité
La formule physique pour annuler la gravité.
GRAVITY * rootPart.AssemblyMass
VectorForce
Applique une force continue sur une Part.
force.Force = Vector3.new(0, p, 0)
Attachment
Point d’ancrage invisible pour la force.
force.Attachment0 = attachment
AssemblyMass
Masse totale du personnage.
rootPart.AssemblyMass
TouchEnded
Déclenché quand UN contact se termine (attention aux autres contacts !).
Phase 2
GetTouchingParts()
Liste toutes les Parts en contact actuel.
isStillTouching(character)
humanoid:GetState()
État actuel du personnage (Jumping, Freefall, Landed…).
Phase 2
StateChanged
Événement à chaque changement d’état.
Phase 3
PlayerAdded / CharacterAdded
Suivre les joueurs et leurs respawns.
Phase 3
Factorisation
Des fonctions utilitaires au lieu de dupliquer le code.
removeAntiGravityForce(...)
Idées pour aller plus loin
1. Une zone qui donne une force supérieure au poids
Objectif : le joueur monte lentement comme dans un ascenseur de geysers.
Comment faire ? Multiplie la force par un coefficient : Vector3.new(0, GRAVITY * rootPart.AssemblyMass * 1.5, 0) — le joueur s’élève à 50 % de la gravité nette !
2. Zone de lune (gravité réduite)
Objectif : le joueur fait des bonds géants comme sur la Lune.
Comment faire ? Force à 50 % du poids : Vector3.new(0, GRAVITY * rootPart.AssemblyMass * 0.5, 0) et augmenteJumpHeight à 25 !
3. Ajouter des particules de lévitation
Objectif : rendre la zone visuellement magique.
Comment faire ? Ajoute un ParticleEmitter à la zone (rappel du tutoriel des particules !) avec EmissionDirection = Enum.NormalId.Top.
4. Un effet de rotation pendant l’apesanteur
Objectif : le joueur tourne lentement sur lui-même comme un astronaute.
Comment faire ? Ajoute un BodyAngularVelocity ou applique rootPart.AssemblyAngularVelocity = Vector3.new(0, 2, 0) à l’entrée dans la zone.
Défi final :
Crée un niveau spatial complet :
Trois zones : une d’apesanteur (le script du jour), une « lune » (gravité à 50 %), une « geyser » (force à 150 %).
Des plateformes flottantes entre les zones (réutilise le tutoriel TweenService !).
Un checkpoint au sommet (réutilise le tutoriel des checkpoints !) avec une bulle de chat « Bien joué ! » verte à l’arrivée.