Aller au contenu principal

Scripts Lua : Exemples et patterns

Cette page contient des exemples complets fonctionnels de scripts de props et d'objets FreeMinecraftModels, ainsi que des patterns pratiques et bonnes pratiques. Chaque exemple inclut une explication détaillée de ce qu'il fait et pourquoi.

Si vous êtes nouveau dans les scripts, commencez par Pour commencer. Pour les détails complets de l'API, consultez l'API Prop et Objet.


Exemple : Prop invulnérable

Ce que cet exemple enseigne : Le script utile le plus simple -- annuler les dégâts pour que le prop ne puisse pas être cassé.

C'est le script pré-configuré livré avec FreeMinecraftModels.

Fichier de script complet (cliquez pour développer)
return {
api_version = 1,

on_left_click = function(context)
if context.event then
context.event.cancel()
end
end
}

Explication détaillée

  1. Choix du hook -- on_left_click se déclenche lorsqu'un joueur frappe (clic gauche) le prop. Sous le capot, c'est un EntityDamageByEntityEvent sur le support d'armure du prop.

  2. Garde d'événement -- context.event devrait toujours être présent dans ce hook, mais la garde est une bonne pratique.

  3. Annulation -- context.event.cancel() annule l'événement de dégâts, ce qui empêche le support d'armure de prendre des dégâts et d'être détruit.

Utilisation

Ajoutez à la configuration .yml de votre prop :

isEnabled: true
scripts:
- invulnerable.lua

Exemple : Porte interactive

Ce que cet exemple enseigne : Basculer un état au clic droit, jouer et arrêter des animations, et utiliser context.state pour suivre si la porte est ouverte ou fermée.

Fichier de script complet (cliquez pour développer)
local OPEN_ANIMATION = "open"
local CLOSE_ANIMATION = "close"

return {
api_version = 1,

on_spawn = function(context)
context.state.is_open = false
end,

on_left_click = function(context)
-- Make the door invulnerable
if context.event then
context.event.cancel()
end
end,

on_right_click = function(context)
local loc = context.prop.current_location

if context.state.is_open then
-- Close the door
context.prop:stop_animation()
context.prop:play_animation(CLOSE_ANIMATION, true, false)
context.state.is_open = false

if loc then
context.world:play_sound(
"BLOCK_WOODEN_DOOR_CLOSE",
loc.x, loc.y, loc.z,
1.0, 1.0
)
end
else
-- Open the door
context.prop:stop_animation()
context.prop:play_animation(OPEN_ANIMATION, true, false)
context.state.is_open = true

if loc then
context.world:play_sound(
"BLOCK_WOODEN_DOOR_OPEN",
loc.x, loc.y, loc.z,
1.0, 1.0
)
end
end
end
}

Explication détaillée

  1. Constantes à la portée du fichier -- OPEN_ANIMATION et CLOSE_ANIMATION sont définies au-dessus de la table de retour. Cela les rend faciles à modifier pour différents fichiers de modèle qui pourraient utiliser des noms d'animation différents.

  2. Initialisation de l'état -- on_spawn définit context.state.is_open = false. L'état persiste à travers tous les hooks pour cette instance de prop.

  3. Invulnérabilité -- Le hook on_left_click annule les dégâts afin que la porte ne puisse pas être cassée par accident.

  4. Logique de bascule -- on_right_click vérifie context.state.is_open, arrête toute animation en cours, joue l'animation appropriée, inverse l'état et joue un son. L'appel stop_animation() avant play_animation() assure des transitions propres.

  5. Retour sonore -- context.world:play_sound() joue le nom de l'enum Sound de Bukkit à l'emplacement du prop. Les noms de son doivent être des noms d'enum en MAJUSCULES.

Noms d'animation

Les noms d'animation comme "open" et "close" doivent correspondre à ce qui est défini dans le fichier de modèle. Si l'animation n'est pas trouvée, play_animation() renvoie false et rien ne se passe. Vérifiez votre fichier de modèle pour les noms d'animation exacts.


Exemple : Déclencheur de proximité avec particules

Ce que cet exemple enseigne : Création de zone, surveillance de zone pour les événements d'entrée/sortie, effets de particules et nettoyage.

Fichier de script complet (cliquez pour développer)
local ZONE_RADIUS = 8
local PARTICLE_INTERVAL = 10 -- ticks between particle bursts

return {
api_version = 1,

on_spawn = function(context)
context.state.zone_handle = nil
context.state.particle_task = nil
context.state.players_in_zone = 0

local loc = context.prop.current_location
if loc == nil then return end

-- Create a sphere zone around the prop
local handle = context.zones:create_sphere(loc.x, loc.y, loc.z, ZONE_RADIUS)
context.state.zone_handle = handle

-- Pass placeholder functions to enable on_zone_enter and on_zone_leave.
-- The actual logic lives in the hooks below.
context.zones:watch(handle, function() end, function() end)

-- Start a repeating particle effect at the zone boundary
context.state.particle_task = context.scheduler:run_repeating(0, PARTICLE_INTERVAL, function(tick_context)
local prop_loc = tick_context.prop.current_location
if prop_loc == nil then return end

-- Spawn particles in a ring at the zone boundary
for angle = 0, 350, 30 do
local rad = math.rad(angle)
local px = prop_loc.x + math.cos(rad) * ZONE_RADIUS
local pz = prop_loc.z + math.sin(rad) * ZONE_RADIUS

if (tick_context.state.players_in_zone or 0) > 0 then
-- Flame particles when players are inside
tick_context.world:spawn_particle("FLAME", px, prop_loc.y + 0.5, pz, 1, 0, 0, 0, 0)
else
-- Green particles when zone is empty
tick_context.world:spawn_particle("HAPPY_VILLAGER", px, prop_loc.y + 0.5, pz, 1, 0, 0, 0, 0)
end
end
end)
end,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_zone_enter = function(context)
context.state.players_in_zone = (context.state.players_in_zone or 0) + 1
end,

on_zone_leave = function(context)
context.state.players_in_zone = math.max(0, (context.state.players_in_zone or 0) - 1)
end,

on_destroy = function(context)
-- Clean up the repeating task
if context.state.particle_task then
context.scheduler:cancel(context.state.particle_task)
context.state.particle_task = nil
end
-- Clean up the zone watch
if context.state.zone_handle then
context.zones:unwatch(context.state.zone_handle)
context.state.zone_handle = nil
end
end
}

Explication détaillée

  1. Constantes -- ZONE_RADIUS et PARTICLE_INTERVAL sont à la portée du fichier pour un ajustement facile.

  2. Initialisation de l'état -- on_spawn initialise tous les champs d'état à nil / 0 avant de faire quoi que ce soit d'autre.

  3. Création de zone -- context.zones:create_sphere() crée une zone sphérique centrée sur le prop. Le handle renvoyé est un ID numérique utilisé pour référencer cette zone plus tard.

  4. Surveillance de zone -- context.zones:watch(handle, function() end, function() end) active les hooks on_zone_enter et on_zone_leave. Ces hooks incrémentent et décrémentent un compteur stocké dans context.state ; ils reçoivent aussi le joueur qui traverse via context.player / context.event.player si vous voulez un comportement spécifique au joueur.

  5. Boucle de particules -- Une tâche répétée fait apparaître des particules en anneau autour du prop toutes les demi-secondes. Le type de particule change selon que des joueurs sont dans la zone ou non.

  6. Nettoyage -- on_destroy annule la tâche répétée et arrête la surveillance de la zone. Bien que les deux soient nettoyés automatiquement lorsque le prop est retiré, le nettoyage explicite est une bonne pratique.

Performance des particules

Faire apparaître de nombreuses particules à chaque tick peut impacter les performances. Utilisez un intervalle raisonnable (10-20 ticks) et gardez le nombre de particules bas. L'exemple ci-dessus utilise PARTICLE_INTERVAL = 10 (deux fois par seconde) avec seulement 12 particules par anneau.


Exemple : Prop émetteur de son

Ce que cet exemple enseigne : Jouer des sons à l'interaction, utiliser context.cooldowns et empêcher les interactions en rafale.

Fichier de script complet (cliquez pour développer)
local SOUND_NAME = "BLOCK_NOTE_BLOCK_HARP"
local COOLDOWN_TICKS = 40 -- 2 seconds between sounds

return {
api_version = 1,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_right_click = function(context)
-- Prevent spam
if not context.cooldowns:check_local("sound", COOLDOWN_TICKS) then
return
end

local loc = context.prop.current_location
if loc == nil then return end

-- Play the sound
context.world:play_sound(SOUND_NAME, loc.x, loc.y, loc.z, 1.0, 1.0)

-- Show some particles
context.world:spawn_particle("NOTE", loc.x, loc.y + 1.5, loc.z, 5, 0.3, 0.3, 0.3, 0)
end
}

Explication détaillée

  1. Pattern de cooldown -- context.cooldowns:check_local("sound", COOLDOWN_TICKS) vérifie si le cooldown "sound" est prêt et, si c'est le cas, le démarre immédiatement. Cela garde le gestionnaire de clic petit et évite de créer des tâches de scheduler juste pour réinitialiser un flag.

  2. Lecture du son -- context.world:play_sound() prend le nom de l'enum Sound de Bukkit en MAJUSCULES, les coordonnées, le volume et la hauteur.

  3. Retour par particules -- Des particules de note apparaissent au-dessus du prop lorsque le son est joué, donnant un indice visuel.

  4. Invulnérabilité -- Le hook on_left_click annule les dégâts comme d'habitude.

Implémentation du cooldown

Utilisez context.cooldowns pour les cooldowns normaux. Réservez context.state plus scheduler:run_later() pour les cas où vous avez besoin de changements d'état personnalisés à la fin du délai.


Exemple : Prop ambiant animé

Ce que cet exemple enseigne : Démarrer une animation en boucle au spawn, avec un émetteur de particules basé sur les ticks.

Fichier de script complet (cliquez pour développer)
return {
api_version = 1,

on_spawn = function(context)
-- Start the idle animation immediately, looping
context.prop:play_animation("idle", false, true)

-- Emit ambient particles every 40 ticks (2 seconds)
context.state.ambient_task = context.scheduler:run_repeating(0, 40, function(tick_context)
local loc = tick_context.prop.current_location
if loc == nil then return end

tick_context.world:spawn_particle(
"ENCHANT",
loc.x, loc.y + 1, loc.z,
10, 0.5, 0.5, 0.5, 0.05
)
end)
end,

on_left_click = function(context)
if context.event then
context.event.cancel()
end
end,

on_destroy = function(context)
if context.state.ambient_task then
context.scheduler:cancel(context.state.ambient_task)
end
end
}

Explication détaillée

  1. Démarrage automatique de l'animation -- on_spawn joue immédiatement une animation "idle" en boucle. Le false pour blend signifie qu'elle démarre à neuf sans mélange depuis une animation précédente. Le true pour loop signifie qu'elle se répète indéfiniment.

  2. Particules ambiantes -- Une tâche répétée fait apparaître des particules de table d'enchantement au-dessus du prop toutes les 2 secondes, créant un effet ambiant magique.

  3. Nettoyage -- on_destroy annule la tâche de particules.


Exemple : Chaise pour s'asseoir

Ce que cet exemple enseigne : Monter un joueur sur un prop au clic droit, le démonter au clic gauche, et utiliser context.event.player pour obtenir le joueur qui interagit.

Fichier de script complet (cliquez pour développer)
return {
api_version = 1,

on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end

-- Check if the player is already sitting on this prop
local passengers = context.prop:get_passengers()
for i = 1, #passengers do
if passengers[i].uuid == player.uuid then
-- Player is already seated, do nothing on right-click
return
end
end

-- Mount the player on the chair
context.prop:mount(player)

local loc = context.prop.current_location
if loc then
context.world:play_sound("BLOCK_WOOD_PLACE", loc.x, loc.y, loc.z, 0.8, 1.2)
end
end,

on_left_click = function(context)
-- Cancel damage so the chair is invulnerable
if context.event then
context.event.cancel()
end

local player = context.event and context.event.player
if not player then return end

-- Check if the player is sitting and dismount them
local passengers = context.prop:get_passengers()
for i = 1, #passengers do
if passengers[i].uuid == player.uuid then
context.prop:dismount(player)

local loc = context.prop.current_location
if loc then
context.world:play_sound("BLOCK_WOOD_BREAK", loc.x, loc.y, loc.z, 0.8, 1.0)
end
return
end
end
end
}

Explication détaillée

  1. Clic droit pour s'asseoir -- on_right_click obtient le joueur depuis context.event.player, vérifie s'il est déjà passager (pour éviter un double montage), et appelle context.prop:mount(player) pour l'asseoir sur le support d'armure du prop.

  2. Clic gauche pour se lever -- on_left_click annule l'événement de dégâts (invulnérabilité), puis vérifie si le joueur qui frappe est actuellement passager. Si c'est le cas, context.prop:dismount(player) l'éjecte.

  3. Vérification des passagers -- context.prop:get_passengers() renvoie un tableau de tables d'entités. Nous comparons les UUID pour trouver le joueur qui interagit dans la liste.

  4. Retour sonore -- Un son de pose de bois est joué en s'asseyant et un son de casse de bois en se levant, donnant un retour tactile.


Exemple : Sanctuaire de bénédiction

Ce que cet exemple enseigne : Vérifier l'objet tenu par le joueur, consommer des objets, appliquer des effets de potion aléatoires, gestion du cooldown, et retour par particules/son.

Fichier de script complet (cliquez pour développer)
local COOLDOWN_TICKS = 600  -- 30 seconds between uses

local BLESSINGS = {
{ effect = "speed", name = "Swiftness" },
{ effect = "strength", name = "Strength" },
{ effect = "regeneration", name = "Regeneration" },
{ effect = "resistance", name = "Resistance" },
{ effect = "jump_boost", name = "Leap" },
{ effect = "haste", name = "Haste" },
}

return {
api_version = 1,

on_spawn = function(context)
context.state.on_cooldown = false
end,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end

-- Check cooldown
if context.state.on_cooldown then
player:send_message("&eThe shrine is recharging... Please wait.")
return
end

-- Check if the player is holding a gold ingot
local held = player:get_held_item()
if not held or held.type ~= "GOLD_INGOT" then
player:send_message("&eThe shrine demands a gold offering...")
return
end

-- Consume one gold ingot
player:consume_held_item(1)

-- Play blessing effects
local loc = context.prop.current_location
if loc then
context.world:spawn_particle("ENCHANT", loc.x, loc.y + 1.5, loc.z, 30, 0.5, 0.5, 0.5, 0.5)
context.world:spawn_particle("HAPPY_VILLAGER", loc.x, loc.y + 1, loc.z, 10, 0.3, 0.3, 0.3, 0)
context.world:play_sound("BLOCK_BEACON_ACTIVATE", loc.x, loc.y, loc.z, 1.0, 1.5)
end

-- Apply a random blessing
local chosen = BLESSINGS[math.random(#BLESSINGS)]
player:add_potion_effect(chosen.effect, 600, 1) -- 30 seconds, level II
player:send_message("&aThe shrine blesses you with " .. chosen.name .. "!")

-- Set cooldown
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end
}

Explication détaillée

  1. Vérification de l'objet -- player:get_held_item() renvoie une table avec type, amount et display_name pour l'objet en main principale (ou nil si vide). held.type est le nom de matériau Bukkit en majuscules, donc nous le comparons à "GOLD_INGOT".

  2. Consommation de l'objet -- player:consume_held_item(1) retire un objet de la pile en main principale du joueur.

  3. Buff aléatoire -- La table BLESSINGS à la portée du fichier liste les effets positifs disponibles. math.random(#BLESSINGS) en choisit un au hasard. player:add_potion_effect(effect, duration, amplifier) l'applique -- 600 ticks correspond à 30 secondes, l'amplificateur 1 est le niveau II.

  4. Cooldown -- Le même pattern flag-booléen-plus-scheduler que dans l'exemple du Prop émetteur de son. Un cooldown de 30 secondes empêche le spam du sanctuaire.

  5. Retour -- Des particules d'enchantement et de villageois content plus un son d'activation de balise créent une sensation de « bénédiction divine ».


Exemple : Sanctuaire maudit

Ce que cet exemple enseigne : Effets de potion négatifs, frappes de foudre, apparition d'entités, et logique de branchement basée sur les offrandes du joueur.

Fichier de script complet (cliquez pour développer)
local COOLDOWN_TICKS = 600  -- 30 seconds between uses

local BUFFS = {
{ effect = "speed", name = "Swiftness" },
{ effect = "strength", name = "Strength" },
{ effect = "regeneration", name = "Regeneration" },
{ effect = "resistance", name = "Resistance" },
}

local CURSES = {
{ effect = "slowness", name = "Slowness" },
{ effect = "weakness", name = "Weakness" },
{ effect = "poison", name = "Poison" },
{ effect = "mining_fatigue", name = "Mining Fatigue" },
}

local ZOMBIE_COUNT = 4

return {
api_version = 1,

on_spawn = function(context)
context.state.on_cooldown = false
end,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end

-- Check cooldown
if context.state.on_cooldown then
player:send_message("&7The dark shrine pulses with residual energy...")
return
end

local loc = context.prop.current_location
if loc == nil then return end

-- Check if the player is holding a gold ingot
local held = player:get_held_item()
if not held or held.type ~= "GOLD_INGOT" then
-- No offering -- punish the player!
player:send_message("&4The shrine demands tribute! You dare approach empty-handed?!")

-- Strike lightning on the player
local player_loc = player.current_location
if player_loc then
context.world:strike_lightning(player_loc.x, player_loc.y, player_loc.z)
end

-- Spawn a horde of zombies around the shrine
for i = 1, ZOMBIE_COUNT do
local angle = math.rad((360 / ZOMBIE_COUNT) * i)
local spawn_x = loc.x + math.cos(angle) * 3
local spawn_z = loc.z + math.sin(angle) * 3
context.world:spawn_entity("zombie", spawn_x, loc.y, spawn_z)
end

-- Apply a random curse
local chosen_curse = CURSES[math.random(#CURSES)]
player:add_potion_effect(chosen_curse.effect, 400, 1) -- 20 seconds, level II
player:send_message("&cThe shrine curses you with " .. chosen_curse.name .. "!")

-- Ominous effects
context.world:spawn_particle("SMOKE", loc.x, loc.y + 1, loc.z, 30, 0.5, 0.5, 0.5, 0.05)
context.world:play_sound("ENTITY_WITHER_AMBIENT", loc.x, loc.y, loc.z, 1.0, 0.5)
else
-- Gold offered -- reward the player
player:consume_held_item(1)

-- Apply a random buff
local chosen_buff = BUFFS[math.random(#BUFFS)]
player:add_potion_effect(chosen_buff.effect, 600, 1) -- 30 seconds, level II
player:send_message("&aThe dark shrine accepts your offering. You are blessed with " .. chosen_buff.name .. "!")

-- Positive feedback
context.world:spawn_particle("ENCHANT", loc.x, loc.y + 1.5, loc.z, 30, 0.5, 0.5, 0.5, 0.5)
context.world:play_sound("BLOCK_BEACON_ACTIVATE", loc.x, loc.y, loc.z, 1.0, 0.8)
end

-- Set cooldown regardless of path
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end
}

Explication détaillée

  1. Branchement sur l'offrande -- Le script vérifie player:get_held_item() et emprunte l'un de deux chemins : punition si le joueur n'a pas d'or, ou récompense s'il en a.

  2. Frappe de foudre -- context.world:strike_lightning(x, y, z) frappe une vraie foudre (qui inflige des dégâts) à la position du joueur. La position du joueur est lue depuis player.current_location.

  3. Apparition de zombies -- context.world:spawn_entity("zombie", x, y, z) fait apparaître des zombies vanilla. La boucle les répartit uniformément en cercle autour du sanctuaire à l'aide de la trigonométrie.

  4. Effets de potion négatifs -- player:add_potion_effect("poison", 400, 1) applique 20 secondes de Poison II. Les noms d'effet sont des chaînes en minuscules correspondant aux noms PotionEffectType de Bukkit.

  5. Chemin de récompense -- Lorsque de l'or est offert, le sanctuaire consomme un lingot et applique un effet positif aléatoire, reflétant le comportement du Sanctuaire de bénédiction.

  6. Cooldown -- Un cooldown de 30 secondes s'applique quel que soit le chemin emprunté, empêchant la punition ou la récompense en rafale.

Performance de l'apparition d'entités

Faire apparaître plusieurs entités à la fois peut impacter les performances du serveur. Gardez le nombre bas (4-6) et envisagez d'ajouter un cooldown par joueur si de nombreux joueurs utilisent le sanctuaire simultanément.


Exemple : Globe tournant

Ce que cet exemple enseigne : Jouer une animation chronométrée à l'interaction, programmer l'arrêt d'une animation, et effets sonores mécaniques.

Fichier de script complet (cliquez pour développer)
local SPIN_ANIMATION = "spin"
local SPIN_DURATION = 100 -- 5 seconds in ticks

return {
api_version = 1,

on_spawn = function(context)
context.state.is_spinning = false
end,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_right_click = function(context)
-- Prevent starting a new spin while already spinning
if context.state.is_spinning then
return
end

local loc = context.prop.current_location
if loc == nil then return end

-- Start the spin animation (non-looping)
context.prop:play_animation(SPIN_ANIMATION, true, false)
context.state.is_spinning = true

-- Play a mechanical clicking sound
context.world:play_sound("BLOCK_CHAIN_PLACE", loc.x, loc.y, loc.z, 1.0, 1.5)

-- Schedule the animation to stop after 5 seconds
context.scheduler:run_later(SPIN_DURATION, function(later_context)
later_context.prop:stop_animation()
later_context.state.is_spinning = false

local stop_loc = later_context.prop.current_location
if stop_loc then
later_context.world:play_sound("BLOCK_CHAIN_FALL", stop_loc.x, stop_loc.y, stop_loc.z, 1.0, 0.8)
end
end)
end
}

Explication détaillée

  1. Garde d'état -- context.state.is_spinning empêche plusieurs demandes de rotation qui se chevauchent. Le flag est défini lorsque la rotation démarre et effacé lorsque l'arrêt programmé se déclenche.

  2. Animation chronométrée -- play_animation(SPIN_ANIMATION, true, false) joue l'animation une fois (sans boucle). L'appel scheduler:run_later(100, ...) arrête l'animation après exactement 5 secondes, au cas où l'animation elle-même serait plus longue ou en boucle.

  3. Sons mécaniques -- BLOCK_CHAIN_PLACE donne un son de démarrage cliquetant/mécanique ; BLOCK_CHAIN_FALL donne un son d'arrêt en fin de course. Ajustez la hauteur selon vos goûts.

  4. Contexte du callback -- Le callback run_later utilise later_context (et non context) pour tous les accès à l'état et au monde. C'est le pattern correct pour les callbacks de scheduler.

Noms d'animation

Le nom d'animation "spin" doit correspondre à ce qui est défini dans votre fichier de modèle. Si votre modèle utilise un nom différent (par ex. "rotate", "turn"), mettez à jour la constante SPIN_ANIMATION en conséquence.


Exemple : Prop jumpscare

Ce que cet exemple enseigne : Déclencheurs de zone de proximité, effets de frayeur one-shot avec un long cooldown, et combinaison de son/particules/animation pour un impact dramatique.

Fichier de script complet (cliquez pour développer)
local SCARE_RADIUS = 3
local COOLDOWN_TICKS = 1200 -- 60 seconds between scares
local SCARE_ANIMATION = "jumpscare"

return {
api_version = 1,

on_spawn = function(context)
context.state.on_cooldown = false
context.state.zone_handle = nil

local loc = context.prop.current_location
if loc == nil then return end

-- Create a small sphere zone around the prop
local handle = context.zones:create_sphere(loc.x, loc.y, loc.z, SCARE_RADIUS)
context.state.zone_handle = handle

-- Watch for players entering the zone. The actual scare logic
-- lives in on_zone_enter below.
context.zones:watch(handle, function() end, nil)
end,

on_zone_enter = function(context)
if context.state.on_cooldown then
return
end

local scare_loc = context.prop.current_location
if scare_loc == nil then return end

-- Play the jumpscare animation
context.prop:stop_animation()
context.prop:play_animation(SCARE_ANIMATION, false, false)

-- Scary sound
context.world:play_sound(
"ENTITY_GHAST_SCREAM",
scare_loc.x, scare_loc.y, scare_loc.z,
1.0, 0.7
)

-- Burst of smoke particles
context.world:spawn_particle(
"CAMPFIRE_SIGNAL_SMOKE",
scare_loc.x, scare_loc.y + 1, scare_loc.z,
20, 0.5, 0.5, 0.5, 0.05
)

-- Set cooldown so it does not trigger again immediately
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_destroy = function(context)
-- Clean up the zone watch
if context.state.zone_handle then
context.zones:unwatch(context.state.zone_handle)
context.state.zone_handle = nil
end
end
}

Explication détaillée

  1. Zone de proximité -- context.zones:create_sphere() crée une zone d'un rayon de 3 blocs. context.zones:watch(handle, function() end, nil) active on_zone_enter, qui se déclenche lorsqu'un joueur y pénètre.

  2. Effets de frayeur -- Le hook on_zone_enter joue une animation "jumpscare", un son de cri de ghast, et fait apparaître des particules de fumée de feu de camp. La combinaison crée un effet soudain et effrayant.

  3. Cooldown de 60 secondes -- Le pattern de flag booléen empêche la frayeur de se déclencher de façon répétée. Une fois déclenché, le prop reste silencieux pendant 60 secondes (COOLDOWN_TICKS = 1200), puis se réarme.

  4. Pas de hook de sortie -- Le troisième argument nil de context.zones:watch() signifie que nous ne nous soucions pas du moment où les joueurs quittent la zone.

  5. Nettoyage -- on_destroy arrête la surveillance de la zone. Bien que les zones soient nettoyées automatiquement lorsque le prop est retiré, le nettoyage explicite est une bonne pratique.

Conception de la frayeur

Pour un meilleur effet de jumpscare, cachez le prop au coin d'un mur ou dans une zone sombre. Le rayon de 3 blocs garantit que le joueur est proche avant que la frayeur ne se déclenche. Ajustez SCARE_RADIUS et COOLDOWN_TICKS selon vos goûts.


Exemple : Prop invocateur de gobelin

Ce que cet exemple enseigne : Utiliser prop:spawn_elitemobs_boss() pour faire apparaître un boss personnalisé depuis une interaction de prop, avec un repli gracieux si EliteMobs n'est pas installé.

Fichier de script complet (cliquez pour développer)
local BOSS_FILE = "goblin_warrior.yml"
local COOLDOWN_TICKS = 200 -- 10 seconds between spawns

return {
api_version = 1,

on_spawn = function(context)
context.state.on_cooldown = false
end,

on_left_click = function(context)
-- Make invulnerable
if context.event then
context.event.cancel()
end
end,

on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end

-- Prevent spawn spam
if context.state.on_cooldown then
player:send_message("&7The spawner is recharging...")
return
end

local loc = context.prop.current_location
if loc == nil then return end

-- Try to spawn the EliteMobs boss
local boss = context.prop:spawn_elitemobs_boss(BOSS_FILE, loc.x, loc.y + 1, loc.z)

if boss then
-- Success -- play spawn effects
context.world:spawn_particle("FLAME", loc.x, loc.y + 1, loc.z, 20, 0.5, 0.5, 0.5, 0.05)
context.world:play_sound("ENTITY_EVOKER_PREPARE_SUMMON", loc.x, loc.y, loc.z, 1.0, 1.0)
player:send_message("&cA goblin warrior emerges!")
else
-- EliteMobs is not installed or the boss file was not found
context.log:warn("Could not spawn boss '" .. BOSS_FILE .. "' -- is EliteMobs installed?")
player:send_message("&7The spawner fizzles... (EliteMobs not available)")

-- Fizzle particles as visual feedback
context.world:spawn_particle("SMOKE", loc.x, loc.y + 1, loc.z, 10, 0.3, 0.3, 0.3, 0.02)
context.world:play_sound("BLOCK_FIRE_EXTINGUISH", loc.x, loc.y, loc.z, 0.8, 1.2)
end

-- Set cooldown
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end
}

Explication détaillée

  1. Apparition de boss -- context.prop:spawn_elitemobs_boss(filename, x, y, z) fait apparaître un boss personnalisé EliteMobs aux coordonnées données. Le nom de fichier doit correspondre à un fichier .yml dans le dossier custombosses d'EliteMobs.

  2. Repli gracieux -- spawn_elitemobs_boss() renvoie nil si EliteMobs n'est pas installé ou si le fichier de boss n'existe pas. Le script gère cela avec un message de log d'avertissement, un effet de particules de raté, et un message au joueur expliquant l'échec.

  3. Décalage d'apparition -- Le boss apparaît à loc.y + 1 (un bloc au-dessus du prop) pour éviter qu'il ne s'enfonce dans le prop ou le sol.

  4. Cooldown -- Un cooldown de 10 secondes empêche les joueurs d'inonder la zone de guerriers gobelins. Ajustez COOLDOWN_TICKS selon vos besoins de gameplay.

  5. Distinction visuelle/audio -- Le succès utilise des particules de flamme et un son d'invocation d'evoker pour une apparition dramatique. L'échec utilise de la fumée et un son d'extinction de feu pour un effet de « raté » clair, afin que le joueur sache que quelque chose a mal tourné sans vérifier la console.

Intégration EliteMobs

Le nom de fichier du boss (par ex. "goblin_warrior.yml") doit correspondre à une configuration de boss personnalisé existante dans EliteMobs. Si vous distribuez une carte ou un donjon qui utilise ce script, incluez le fichier de configuration du boss et documentez la dépendance EliteMobs.


Exemple : Épée de l'onde de choc glaciale (script d'objet)

Ce que cet exemple enseigne : Un script d'objet complet démontrant l'initialisation de l'état dans on_equip, on_shift_right_click pour déclencher une capacité, scheduler:run_repeating() pour une onde en expansion, get_nearby_entities() pour trouver des cibles, les méthodes de combat d'entité, les effets de particules/son, la modification du terrain avec get_highest_block_y(), show_action_bar() pour le retour d'UI, et la protection contre les entités non vivantes.

Fichier de script complet (cliquez pour développer)
-- Frost Shockwave Sword
-- Shift + Right-click to release a horizontal frost wave

local WAVE_MAX_RADIUS = 12
local WAVE_SPEED = 2
local WAVE_TICK_INTERVAL = 2
local WAVE_DAMAGE = 4.0
local WAVE_KNOCKBACK = 1.2
local WAVE_CONE_ANGLE = 70

return {
api_version = 1,

on_equip = function(context)
context.state.wave_active = false
end,

on_shift_right_click = function(context)
if context.state.wave_active then return end
local loc = context.player.current_location
local yaw_rad = math.rad(loc.yaw)
local dir_x = -math.sin(yaw_rad)
local dir_z = math.cos(yaw_rad)

context.event:cancel()
context.state.wave_active = true
context.state.hit_entities = {}
context.state.destroyed_blocks = {}

local current_radius = 1
local origin_x, origin_y, origin_z = loc.x, loc.y, loc.z
local player_uuid = context.player.uuid

context.player:show_action_bar("&b&lFrost Shockwave!", 40)
context.world:play_sound("ENTITY_PLAYER_ATTACK_SWEEP", origin_x, origin_y, origin_z, 1.5, 0.5)

local task_id = context.scheduler:run_repeating(0, WAVE_TICK_INTERVAL, function(tick_context)
if current_radius > WAVE_MAX_RADIUS then
tick_context.state.wave_active = false
tick_context.scheduler:cancel(tick_context.state.wave_task)
return
end

local half_angle = math.rad(WAVE_CONE_ANGLE / 2)
local steps = math.floor(current_radius * 8)
for i = 0, steps do
local angle = (i / steps) * 2 * math.pi
local px = origin_x + math.cos(angle) * current_radius
local pz = origin_z + math.sin(angle) * current_radius
local to_x, to_z = px - origin_x, pz - origin_z
local to_len = math.sqrt(to_x * to_x + to_z * to_z)
if to_len > 0 then
local dot = (to_x * dir_x + to_z * dir_z) / to_len
if dot >= math.cos(half_angle) then
local ground_y = tick_context.world:get_highest_block_y(math.floor(px), math.floor(pz))
tick_context.world:spawn_particle("SNOWFLAKE", px, ground_y + 1.0, pz, 3, 0.3, 0.2, 0.3, 0.02)
local bx, bz = math.floor(px), math.floor(pz)
local block_key = bx .. "," .. bz
if not tick_context.state.destroyed_blocks[block_key] then
tick_context.state.destroyed_blocks[block_key] = true
local block_type = tick_context.world:get_block_at(bx, ground_y, bz)
if block_type ~= "bedrock" and block_type ~= "obsidian" and block_type ~= "barrier" then
tick_context.world:set_block_at(bx, ground_y, bz, "AIR")
end
end
end
end
end

local entities = tick_context.world:get_nearby_entities(origin_x, origin_y, origin_z, current_radius + 1)
for _, entity in ipairs(entities) do
if entity.uuid ~= player_uuid and not tick_context.state.hit_entities[entity.uuid] then
local eloc = entity.current_location
local dx, dz = eloc.x - origin_x, eloc.z - origin_z
local dist = math.sqrt(dx * dx + dz * dz)
if dist >= current_radius - 2 and dist <= current_radius + 1 and dist > 0 then
local dot = (dx * dir_x + dz * dir_z) / dist
if dot >= math.cos(half_angle) and entity.damage then
tick_context.state.hit_entities[entity.uuid] = true
entity:damage(WAVE_DAMAGE)
entity:push((dx/dist)*WAVE_KNOCKBACK, 0.3, (dz/dist)*WAVE_KNOCKBACK)
if entity.is_alive then entity:add_potion_effect("SLOWNESS", 60, 1) end
tick_context.world:spawn_particle("SNOWFLAKE", eloc.x, eloc.y+1, eloc.z, 10, 0.5, 0.5, 0.5, 0.1)
end
end
end
end

tick_context.world:play_sound("BLOCK_GLASS_BREAK",
origin_x + dir_x * current_radius, origin_y,
origin_z + dir_z * current_radius, 0.8, 1.5)
current_radius = current_radius + WAVE_SPEED
end)

context.state.wave_task = task_id
end
}

Explication détaillée

  1. Initialisation de l'état -- on_equip définit wave_active = false pour empêcher plusieurs ondes de choc qui se chevauchent. Ce hook se déclenche lorsque le joueur équipe l'épée.

  2. Cône directionnel -- Le yaw du joueur est converti en vecteur de direction (dir_x, dir_z). Un cône de 70 degrés devant le joueur détermine l'étalement de l'onde.

  3. Onde en expansion -- scheduler:run_repeating() s'exécute tous les 2 ticks. À chaque tick, le rayon de l'onde croît de WAVE_SPEED. Des particules apparaissent en anneau, mais uniquement dans l'angle du cône.

  4. Destruction du terrain -- get_highest_block_y() trouve le niveau du sol à chaque point. Les blocs de surface sont détruits (mis à AIR), avec bedrock/obsidienne/barrière exclus. Une table destroyed_blocks empêche le double traitement.

  5. Ciblage des entités -- get_nearby_entities() trouve toutes les entités près de l'origine. Le script vérifie que chaque entité est dans la bande actuelle de l'onde, à l'intérieur du cône, et n'a pas déjà été touchée. La garde if entity.damage then est cruciale -- get_nearby_entities() renvoie TOUTES les entités (supports d'armure, objets, etc.), pas seulement les vivantes.

  6. Effets de combat -- Les entités touchées prennent des dégâts, sont repoussées dans la direction de l'onde, et reçoivent 3 secondes de Lenteur II.

  7. Retour au joueur -- show_action_bar() affiche « Frost Shockwave! » pendant 40 ticks. Des sons de bris de verre avancent avec le front de l'onde.

  8. Nettoyage -- Lorsque le rayon dépasse WAVE_MAX_RADIUS, la tâche répétée s'annule elle-même et réinitialise le flag wave_active.

Sécurité du type d'entité

Vérifiez toujours if entity.damage then avant d'appeler entity:damage(), entity:push(), ou entity:add_potion_effect(). La méthode get_nearby_entities() renvoie toutes les entités à portée, y compris les non vivantes comme les supports d'armure, les objets lâchés et les orbes d'expérience qui n'ont pas ces méthodes.


Exemple : Baguette de soin (script d'objet)

Ce que cet exemple enseigne : Un script d'objet plus simple montrant l'activation au clic droit, la gestion du cooldown, la méthode item:consume() pour un nombre limité d'utilisations, et le soin avec des effets de potion.

Fichier de script complet (cliquez pour développer)
-- Healing Wand
-- Right-click to heal yourself. Consumes one charge per use.

local HEAL_AMOUNT = 6.0 -- 3 hearts
local COOLDOWN_TICKS = 60 -- 3 seconds
local REGEN_DURATION = 40 -- 2 seconds of regen
local REGEN_AMPLIFIER = 1 -- Regeneration II

return {
api_version = 1,

on_right_click = function(context)
-- Check remaining charges
local uses = context.item:get_uses()
if uses <= 0 then
context.player:show_action_bar("&c&lOut of charges!", 40)
context.world:play_sound("BLOCK_FIRE_EXTINGUISH",
context.player.current_location.x,
context.player.current_location.y,
context.player.current_location.z,
0.8, 1.5)
return
end

-- Check cooldown
if not context.cooldowns:check_local("heal", COOLDOWN_TICKS) then
context.player:show_action_bar("&cWand recharging...", 20)
return
end

-- Cancel the event so we don't interact with blocks
if context.event then
context.event:cancel()
end

-- Consume one charge
context.item:set_uses(uses - 1)

-- Heal the player
local loc = context.player.current_location
context.player:add_potion_effect("REGENERATION", REGEN_DURATION, REGEN_AMPLIFIER)

-- Visual and audio feedback
context.world:spawn_particle("HEART", loc.x, loc.y + 1.5, loc.z, 8, 0.4, 0.3, 0.4, 0)
context.world:spawn_particle("HAPPY_VILLAGER", loc.x, loc.y + 1, loc.z, 15, 0.5, 0.5, 0.5, 0)
context.world:play_sound("ENTITY_PLAYER_LEVELUP", loc.x, loc.y, loc.z, 0.7, 1.8)

-- UI feedback
context.player:show_action_bar("&a&lHealed! &7(" .. (uses - 1) .. " charges left)", 40)
end
}

Explication détaillée

  1. Pattern de cooldown -- context.cooldowns:check_local("heal", COOLDOWN_TICKS) vérifie et démarre le cooldown de recharge en un seul appel. Les cooldowns locaux d'objet persistent à travers les cycles d'équipement et de déséquipement.

  2. Système de charges -- context.item:get_uses() lit un compteur d'utilisations personnalisé depuis le PDC de l'objet. Chaque utilisation le décrémente avec set_uses(). Lorsque les charges atteignent 0, la baguette joue un son de raté et refuse de s'activer.

  3. Soin -- Plutôt que de définir directement la santé, le script applique Régénération II pendant 2 secondes. Cela fonctionne plus naturellement avec le système de santé de Minecraft et se cumule correctement avec d'autres effets.

  4. Annulation de l'événement -- context.event:cancel() empêche le clic droit d'interagir avec les blocs proches (poser des blocs, ouvrir des portes, etc.).

  5. Retour d'UI -- show_action_bar() indique au joueur le résultat de son action et les charges restantes. Des messages différents pour le cooldown, le manque de charges et le soin réussi rendent l'objet réactif.

Obtenir des objets personnalisés

Utilisez /fmm giveitem <id> pour obtenir un objet personnalisé correctement tagué. Les objets créés par d'autres moyens peuvent manquer de la clé PDC fmm_item_id, ce qui signifie que les scripts ne s'activeront pas pour eux.


Bonnes pratiques

  • Commencez par un hook minuscule et vérifiez. Écrivez un seul on_spawn qui envoie un message de log. Confirmez qu'il se déclenche. Puis construisez à partir de là.

  • Gardez les fonctions d'aide locales. Déclarez les helpers comme local function toggle_door(context) au-dessus de la table de retour. Cela les garde hors de la portée globale.

  • Initialisez tout l'état dans on_spawn. Si vous lisez context.state.is_open dans on_right_click mais ne le définissez jamais dans on_spawn, il sera nil et vos comparaisons peuvent se comporter de manière inattendue.

  • Annulez les tâches répétées une fois terminées. Chaque run_repeating devrait avoir un cancel correspondant dans on_destroy. Les tâches non nettoyées gaspillent du CPU.

  • Utilisez des contextes de callback de scheduler frais. Les callbacks de scheduler reçoivent un paramètre de contexte frais. Utilisez toujours ce paramètre à l'intérieur du callback, et non le context extérieur.

  • Gardez on_game_tick léger. Si vous définissez ce hook, il s'exécute à chaque tick du serveur (20 fois par seconde). Encadrez le travail coûteux derrière context.cooldowns ou un intervalle de scheduler.

  • Rendez les props invulnérables par défaut. À moins que vous ne vouliez que le prop soit cassable, incluez l'annulation des dégâts dans on_left_click dans chaque script.

  • Utilisez les MAJUSCULES pour les enums Bukkit. Les noms de son et de particule doivent utiliser le format constant d'enum Bukkit (par ex. "FLAME", et non "flame").


Erreurs courantes de débutant

  • Utiliser le context extérieur à l'intérieur d'un callback de scheduler. Le contexte extérieur capture un instantané au moment où le hook s'est exécuté. À l'intérieur des callbacks, utilisez toujours le propre paramètre du callback.

  • Oublier d'annuler les tâches répétées. Si vous démarrez un run_repeating dans on_spawn mais ne l'annulez jamais, la tâche s'exécute jusqu'à ce que le prop soit retiré.

  • Ne pas initialiser l'état dans on_spawn. Lire context.state.x avant de le définir renvoie nil, ce qui peut casser votre logique silencieusement.

  • Mauvais noms d'animation. Si play_animation("open") renvoie false, le nom d'animation ne correspond pas à ce qui est dans le fichier de modèle. Vérifiez le modèle pour les noms exacts.

  • Noms de son/particule en minuscules. "flame" ne fonctionne pas -- utilisez "FLAME". L'API convertit en MAJUSCULES en interne pour les particules, mais les noms d'enum Sound doivent être exacts.

  • Oublier api_version = 1. La table renvoyée doit inclure ce champ, sinon FMM ne chargera pas le script.

  • Mettre dans la table renvoyée des fonctions qui ne sont pas des hooks. Les fonctions d'aide doivent être déclarées au-dessus de l'instruction return. Seuls les noms de hook (on_spawn, on_right_click, etc.) sont autorisés comme clés dans la table renvoyée.


Liste de vérification QC

Utilisez cette liste de vérification pour valider un script de prop avant de le déployer :

  1. Le fichier renvoie exactement une table avec api_version = 1.
  2. Chaque nom de hook correspond exactement à une entrée dans la liste des hooks de prop ou la liste des hooks d'objet.
  3. context.event est protégé par if context.event then avant d'appeler cancel().
  4. Les champs context.state sont initialisés dans on_spawn.
  5. Chaque appel scheduler:run_repeating(...) a un scheduler:cancel(...) correspondant dans on_destroy.
  6. Les callbacks de scheduler utilisent le propre paramètre de contexte du callback, et non le context extérieur.
  7. Les hooks on_game_tick encadrent le travail coûteux derrière une vérification.
  8. Tous les noms de méthode existent dans la référence de l'API Prop et Objet -- pas d'alias inventés.
  9. Les noms de son et de particule utilisent les noms d'enum Bukkit en MAJUSCULES.
  10. Le script n'appelle aucune opération bloquante ou de longue durée à l'intérieur d'un hook ou d'un callback.

Conseils pour la génération par IA

Si vous voulez que l'IA génère des scripts de prop de manière fiable, assurez-vous que le prompt inclut :

  • Le nom exact du hook -- par ex. on_right_click, et non « quand le joueur clique sur le prop ».
  • Les noms d'animation du fichier de modèle -- l'IA ne peut pas les deviner ; fournissez-les.
  • Les noms d'enum de son -- par ex. "BLOCK_NOTE_BLOCK_HARP", et non « son de harpe ».
  • Les noms d'enum de particule -- par ex. "FLAME", et non « particules de feu ».
  • Si le prop doit être invulnérable -- si oui, incluez on_left_click avec context.event.cancel().
  • N'utilisez que des noms de méthode documentés -- s'il n'est pas sur la page de l'API Prop, il n'existe pas.

Bon exemple de prompt

Écris un script de prop FMM qui joue l'animation « activate » au clic droit, rend le prop invulnérable, fait apparaître des particules FLAME à l'emplacement du prop au clic, joue le son BLOCK_LEVER_CLICK, et a un cooldown de 2 secondes entre les clics en utilisant context.cooldowns:check_local("activate", 40).


Collection d'objets gobelins

Un ensemble de 10 scripts d'objets exemple sur le thème des armes de gobelin est disponible en tant que contenu exemple téléchargeable. Ils ne sont pas inclus dans le jar de base du plugin — les seuls scripts que le plugin lui-même écrit dans scripts/ à la première exécution sont les quatre scripts de prop pré-configurés : invulnerable.lua, pickupable.lua, storage_single.lua et storage_double.lua. Chaque script de gobelin ci-dessous démontre différentes mécaniques de combat, effets de particules et patterns d'utilisation de l'API.

ScriptObjetEffet
goblin_golden_sword.luaGolden Sword25 % de chance de balayage -- inflige des dégâts et repousse tous les ennemis proches
goblin_iron_axe.luaIron Axe20 % de chance d'empalement -- propulse la cible vers le haut avec un visuel de dripstone
goblin_bow.luaBowAttire le mob touché vers le joueur
goblin_crossbow.luaCrossbow33 % de chance de barrage de feux d'artifice
goblin_trident.luaTridentUn dôme de glace enferme la cible pendant 3 s
goblin_iron_hoe.luaIron Hoe15 % de chance de nuages de poison
goblin_mace.luaMaceFrappes de météores depuis le ciel (clic droit)
goblin_spear.luaSpearRayon de feu le long de la visée (shift+clic droit)
goblin_shield.luaShieldDôme de verre lors d'un blocage (réactif)
goblin_iron_sword.luaIron SwordAnneau de ténèbres en chargement (shift+clic droit)
Aucun modèle requis

Ces scripts fonctionnent avec des objets à l'apparence vanilla. Chacun n'a besoin que d'une config YML avec un champ material: -- aucun fichier .bbmodel n'est requis. Par exemple, pour activer le script de l'épée dorée, créez une config YML comme :

isEnabled: true
material: GOLDEN_SWORD
scripts:
- goblin_golden_sword.lua

Prochaines étapes