Scripting Lua: Ejemplos y Patrones
Esta página contiene ejemplos completos y funcionales de scripts de props y objetos de FreeMinecraftModels, además de patrones prácticos y buenas prácticas. Cada ejemplo incluye una explicación de qué hace y por qué.
Si eres nuevo en el scripting, comienza con Primeros Pasos. Para detalles completos de la API, consulta la API de Props y Objetos.
Ejemplo: Prop Invulnerable
Lo que enseña este ejemplo: El script útil más simple -- cancelar el daño para que el prop no pueda ser destruido.
Este es el script predefinido que viene con FreeMinecraftModels.
Archivo de script completo (clic para expandir)
return {
api_version = 1,
on_left_click = function(context)
if context.event then
context.event.cancel()
end
end
}
Explicación Paso a Paso
-
Elección del hook --
on_left_clickse activa cuando un jugador golpea (clic izquierdo) el prop. Internamente, se trata de unEntityDamageByEntityEventen el armor stand subyacente del prop. -
Verificación del evento --
context.eventsiempre debería estar presente en este hook, pero la verificación es una buena práctica. -
Cancelación --
context.event.cancel()cancela el evento de daño, lo que evita que el armor stand reciba daño y sea destruido.
Uso
Agrega lo siguiente a la configuración .yml de tu prop:
isEnabled: true
scripts:
- invulnerable.lua
Ejemplo: Puerta Interactiva
Lo que enseña este ejemplo: Alternar estado con clic derecho, reproducir y detener animaciones, y usar context.state para rastrear si la puerta está abierta o cerrada.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Constantes a nivel de archivo --
OPEN_ANIMATIONyCLOSE_ANIMATIONse definen encima de la tabla de retorno. Esto facilita cambiarlas para distintos archivos de modelo que puedan usar nombres de animación diferentes. -
Inicialización del estado --
on_spawnestablececontext.state.is_open = false. El estado persiste a través de todos los hooks para esta instancia del prop. -
Invulnerabilidad -- El hook
on_left_clickcancela el daño para que la puerta no pueda romperse accidentalmente. -
Lógica de alternancia --
on_right_clickcompruebacontext.state.is_open, detiene cualquier animación en curso, reproduce la animación apropiada, invierte el estado y reproduce un sonido. La llamada astop_animation()antes deplay_animation()asegura transiciones limpias. -
Retroalimentación de sonido --
context.world:play_sound()reproduce el nombre del enum Sound de Bukkit en la ubicación del prop. Los nombres de sonido deben ser nombres de enum en MAYÚSCULAS.
Los nombres de animación como "open" y "close" deben coincidir con lo definido en el archivo del modelo. Si la animación no se encuentra, play_animation() devuelve false y no ocurre nada. Comprueba tu archivo de modelo para conocer los nombres exactos de las animaciones.
Ejemplo: Disparador de Proximidad con Partículas
Lo que enseña este ejemplo: Creación de zonas, observación de zonas para eventos de entrada/salida, efectos de partículas y limpieza.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Constantes --
ZONE_RADIUSyPARTICLE_INTERVALestán a nivel de archivo para facilitar el ajuste. -
Inicialización del estado --
on_spawnconfigura todos los campos de estado anil/0antes de hacer cualquier otra cosa. -
Creación de la zona --
context.zones:create_sphere()crea una zona esférica centrada en el prop. El handle devuelto es un ID numérico usado para referenciar esta zona más tarde. -
Observación de la zona --
context.zones:watch(handle, function() end, function() end)habilita los hookson_zone_enteryon_zone_leave. Esos hooks incrementan y decrementan un contador almacenado encontext.state; también reciben al jugador que cruza comocontext.player/context.event.playersi quieres un comportamiento específico por jugador. -
Bucle de partículas -- Una tarea repetitiva genera partículas en un anillo alrededor del prop cada medio segundo. El tipo de partícula cambia según si hay jugadores en la zona.
-
Limpieza --
on_destroycancela la tarea repetitiva y deja de observar la zona. Aunque ambas se limpian automáticamente cuando se elimina el prop, la limpieza explícita es una buena práctica.
Generar muchas partículas en cada tick puede afectar al rendimiento. Usa un intervalo razonable (10-20 ticks) y mantén bajos los conteos de partículas. El ejemplo de arriba usa PARTICLE_INTERVAL = 10 (dos veces por segundo) con solo 12 partículas por anillo.
Ejemplo: Prop que Emite Sonido
Lo que enseña este ejemplo: Reproducir sonidos al interactuar, usar context.cooldowns y prevenir interacciones en ráfaga.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Patrón de cooldown --
context.cooldowns:check_local("sound", COOLDOWN_TICKS)comprueba si el cooldown"sound"está listo y, si lo está, lo inicia inmediatamente. Esto mantiene pequeño el manejador de clic y evita crear tareas del scheduler solo para reiniciar una bandera. -
Reproducción de sonido --
context.world:play_sound()toma el nombre del enum Sound de Bukkit en MAYÚSCULAS, las coordenadas, el volumen y el tono. -
Retroalimentación de partículas -- Las partículas de nota aparecen sobre el prop cuando se reproduce el sonido, dando una señal visual.
-
Invulnerabilidad -- El hook
on_left_clickcancela el daño como de costumbre.
Usa context.cooldowns para cooldowns normales. Reserva context.state más scheduler:run_later() para casos en los que necesites cambios de estado personalizados al final del retardo.
Ejemplo: Prop Ambiental Animado
Lo que enseña este ejemplo: Iniciar una animación en bucle al generar, con un emisor de partículas basado en ticks.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Auto-inicio de la animación --
on_spawnreproduce inmediatamente una animación"idle"en bucle. Elfalsepara blend significa que empieza desde cero sin mezclarse con una animación anterior. Eltruepara loop significa que se repite indefinidamente. -
Partículas ambientales -- Una tarea repetitiva genera partículas de mesa de encantamientos sobre el prop cada 2 segundos, creando un efecto ambiental mágico.
-
Limpieza --
on_destroycancela la tarea de partículas.
Ejemplo: Silla en la que Sentarse
Lo que enseña este ejemplo: Montar a un jugador en un prop con clic derecho, desmontar con clic izquierdo y usar context.event.player para obtener al jugador que interactúa.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Clic derecho para sentarse --
on_right_clickobtiene al jugador decontext.event.player, comprueba si ya es un pasajero (para evitar montarlo dos veces), y llama acontext.prop:mount(player)para sentarlo en el armor stand del prop. -
Clic izquierdo para levantarse --
on_left_clickcancela el evento de daño (invulnerabilidad), luego comprueba si el jugador que golpea es actualmente un pasajero. Si lo es,context.prop:dismount(player)lo expulsa. -
Comprobación de pasajeros --
context.prop:get_passengers()devuelve un array de tablas de entidad. Comparamos los UUID para encontrar al jugador que interactúa en la lista. -
Retroalimentación de sonido -- Se reproduce un sonido de colocación de madera al sentarse y un sonido de rotura de madera al levantarse, dando una respuesta táctil.
Ejemplo: Santuario de Bendiciones
Lo que enseña este ejemplo: Comprobar el objeto que sostiene el jugador, consumir objetos, aplicar efectos de poción aleatorios, gestión de cooldown y retroalimentación de partículas/sonido.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Comprobación del objeto --
player:get_held_item()devuelve una tabla contype,amountydisplay_namepara el objeto de la mano principal (onilsi está vacía).held.typees el nombre del material Bukkit en mayúsculas, así que lo comparamos con"GOLD_INGOT". -
Consumo del objeto --
player:consume_held_item(1)elimina un objeto de la pila de la mano principal del jugador. -
Mejora aleatoria -- La tabla
BLESSINGSa nivel de archivo lista los efectos positivos disponibles.math.random(#BLESSINGS)elige uno al azar.player:add_potion_effect(effect, duration, amplifier)lo aplica --600ticks son 30 segundos, el amplificador1es nivel II. -
Cooldown -- El mismo patrón de bandera booleana más scheduler del ejemplo del Prop que Emite Sonido. Un cooldown de 30 segundos evita el spam del santuario.
-
Retroalimentación -- Las partículas de encantamiento y de aldeano feliz más un sonido de activación de baliza crean una sensación de "bendición divina".
Ejemplo: Santuario Maldito
Lo que enseña este ejemplo: Efectos de poción negativos, rayos, generación de entidades y lógica de ramificación basada en las ofrendas del jugador.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Ramificación según la ofrenda -- El script comprueba
player:get_held_item()y toma uno de dos caminos: castigo si el jugador no tiene oro, o recompensa si lo tiene. -
Caída de rayo --
context.world:strike_lightning(x, y, z)invoca un rayo real (que hace daño) en la ubicación del jugador. La posición del jugador se lee deplayer.current_location. -
Generación de zombies --
context.world:spawn_entity("zombie", x, y, z)genera zombies vanilla. El bucle los distribuye uniformemente en un círculo alrededor del santuario usando trigonometría. -
Efectos de poción negativos --
player:add_potion_effect("poison", 400, 1)aplica 20 segundos de Veneno II. Los nombres de efecto son strings en minúscula que coinciden con los nombres dePotionEffectTypede Bukkit. -
Camino de recompensa -- Cuando se ofrece oro, el santuario consume un lingote y aplica un efecto positivo aleatorio, replicando el comportamiento del Santuario de Bendiciones.
-
Cooldown -- Se aplica un cooldown de 30 segundos sin importar qué rama se haya tomado, evitando el castigo o la recompensa en ráfaga.
Generar múltiples entidades a la vez puede afectar al rendimiento del servidor. Mantén bajo el conteo (4-6) y considera añadir un cooldown por jugador si muchos jugadores usan el santuario simultáneamente.
Ejemplo: Globo Giratorio
Lo que enseña este ejemplo: Reproducir una animación temporizada al interactuar, programar una detención de la animación y efectos de sonido mecánicos.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Protección de estado --
context.state.is_spinningevita múltiples solicitudes de giro superpuestas. La bandera se establece cuando empieza el giro y se borra cuando se dispara la detención programada. -
Animación temporizada --
play_animation(SPIN_ANIMATION, true, false)reproduce la animación una vez (sin bucle). La llamada ascheduler:run_later(100, ...)detiene la animación después de exactamente 5 segundos, por si la animación en sí es más larga o está en bucle. -
Sonidos mecánicos --
BLOCK_CHAIN_PLACEda un sonido de inicio mecánico/de clic;BLOCK_CHAIN_FALLda un sonido de parada de desaceleración. Ajusta el tono a tu gusto. -
Contexto del callback -- El callback de
run_laterusalater_context(nocontext) para todo el acceso a estado y mundo. Este es el patrón correcto para los callbacks del scheduler.
El nombre de animación "spin" debe coincidir con lo definido en tu archivo de modelo. Si tu modelo usa un nombre diferente (p. ej. "rotate", "turn"), actualiza la constante SPIN_ANIMATION en consecuencia.
Ejemplo: Prop de Susto Repentino
Lo que enseña este ejemplo: Disparadores de zona de proximidad, efectos de susto de un solo disparo con un cooldown largo, y combinación de sonido/partículas/animación para un impacto dramático.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Zona de proximidad --
context.zones:create_sphere()crea una zona de 3 bloques de radio.context.zones:watch(handle, function() end, nil)habilitaon_zone_enter, que se dispara cuando cualquier jugador entra. -
Efectos de susto -- El hook
on_zone_enterreproduce una animación"jumpscare", un sonido de grito de ghast y genera partículas de humo de hoguera. La combinación crea un efecto repentino y sobresaltante. -
Cooldown de 60 segundos -- El patrón de bandera booleana evita que el susto se dispare repetidamente. Una vez disparado, el prop queda en silencio durante 60 segundos (
COOLDOWN_TICKS = 1200), luego se rearma. -
Sin hook de salida -- El tercer argumento
nildecontext.zones:watch()significa que no nos importa cuándo los jugadores salen de la zona. -
Limpieza --
on_destroydeja de observar la zona. Aunque las zonas se limpian automáticamente cuando se elimina el prop, la limpieza explícita es una buena práctica.
Para el mejor efecto de susto, esconde el prop tras una esquina o en una zona oscura. El radio de 3 bloques asegura que el jugador esté cerca antes de que se dispare el susto. Ajusta SCARE_RADIUS y COOLDOWN_TICKS a tu gusto.
Ejemplo: Prop Generador de Goblins
Lo que enseña este ejemplo: Usar prop:spawn_elitemobs_boss() para generar un jefe personalizado a partir de la interacción con un prop, con un fallback elegante si EliteMobs no está instalado.
Archivo de script completo (clic para expandir)
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
}
Explicación Paso a Paso
-
Generación de jefes --
context.prop:spawn_elitemobs_boss(filename, x, y, z)genera un jefe personalizado de EliteMobs en las coordenadas dadas. El nombre de archivo debe coincidir con un archivo.ymlen la carpetacustombossesde EliteMobs. -
Fallback elegante --
spawn_elitemobs_boss()devuelvenilsi EliteMobs no está instalado o el archivo del jefe no existe. El script maneja esto con un mensaje de advertencia en el log, un efecto de partículas de fallo y un mensaje al jugador explicando el fallo. -
Desplazamiento de generación -- El jefe se genera en
loc.y + 1(un bloque por encima del prop) para evitar que el jefe quede atrapado dentro del prop o del suelo. -
Cooldown -- Un cooldown de 10 segundos evita que los jugadores inunden la zona con guerreros goblin. Ajusta
COOLDOWN_TICKSsegún las necesidades de tu jugabilidad. -
Distinción visual/auditiva -- El éxito usa partículas de llama y un sonido de invocación de evoker para una generación dramática. El fallo usa humo y extinción de fuego para un claro efecto de "fallo", de modo que el jugador sepa que algo salió mal sin revisar la consola.
El nombre de archivo del jefe (p. ej. "goblin_warrior.yml") debe corresponder a una configuración de jefe personalizado existente en EliteMobs. Si estás distribuyendo un mapa o mazmorra que usa este script, incluye el archivo de configuración del jefe y documenta la dependencia de EliteMobs.
Ejemplo: Espada de Onda de Choque Helada (Script de Objeto)
Lo que enseña este ejemplo: Un script de objeto completo que demuestra la inicialización de estado con on_equip, on_shift_right_click para activar una habilidad, scheduler:run_repeating() para una onda en expansión, get_nearby_entities() para encontrar objetivos, métodos de combate de entidad, efectos de partículas/sonido, modificación del terreno con get_highest_block_y(), show_action_bar() para retroalimentación de UI, y protección contra entidades no vivientes.
Archivo de script completo (clic para expandir)
-- 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
}
Explicación Paso a Paso
-
Inicialización del estado --
on_equipestablecewave_active = falsepara evitar múltiples ondas de choque superpuestas. Este hook se dispara cuando el jugador equipa la espada. -
Cono direccional -- El yaw del jugador se convierte en un vector de dirección (
dir_x,dir_z). Un cono de 70 grados frente al jugador determina la dispersión de la onda. -
Onda en expansión --
scheduler:run_repeating()se ejecuta cada 2 ticks. En cada tick, el radio de la onda crece segúnWAVE_SPEED. Las partículas se generan en un anillo, pero solo dentro del ángulo del cono. -
Destrucción del terreno --
get_highest_block_y()encuentra el nivel del suelo en cada punto. Los bloques de superficie se destruyen (se ponen a AIR), excluyendo bedrock/obsidiana/barrera. Una tabladestroyed_blocksevita el doble procesamiento. -
Selección de objetivos --
get_nearby_entities()encuentra todas las entidades cercanas al origen. El script comprueba que cada entidad esté dentro de la banda actual de la onda, dentro del cono, y que no haya sido golpeada ya. La protecciónif entity.damage thenes crítica --get_nearby_entities()devuelve TODAS las entidades (armor stands, objetos, etc.), no solo las vivientes. -
Efectos de combate -- Las entidades golpeadas reciben daño, son empujadas en la dirección de la onda y reciben 3 segundos de Lentitud II.
-
Retroalimentación al jugador --
show_action_bar()muestra "Frost Shockwave!" durante 40 ticks. Los sonidos de rotura de cristal avanzan con el frente de la onda. -
Limpieza -- Cuando el radio supera
WAVE_MAX_RADIUS, la tarea repetitiva se cancela a sí misma y reinicia la banderawave_active.
Comprueba siempre if entity.damage then antes de llamar a entity:damage(), entity:push() o entity:add_potion_effect(). El método get_nearby_entities() devuelve todas las entidades en rango, incluyendo las no vivientes como armor stands, objetos soltados y orbes de experiencia que no tienen estos métodos.
Ejemplo: Varita Curativa (Script de Objeto)
Lo que enseña este ejemplo: Un script de objeto más simple que muestra la activación con clic derecho, gestión de cooldown, el método item:consume() para usos limitados, y curación con efectos de poción.
Archivo de script completo (clic para expandir)
-- 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
}
Explicación Paso a Paso
-
Patrón de cooldown --
context.cooldowns:check_local("heal", COOLDOWN_TICKS)comprueba e inicia el cooldown de recarga en una sola llamada. Los cooldowns locales de objeto persisten a través de los ciclos de equipar y desequipar. -
Sistema de cargas --
context.item:get_uses()lee un contador de usos personalizado del PDC del objeto. Cada uso lo decrementa conset_uses(). Cuando las cargas llegan a 0, la varita reproduce un sonido de fallo y se niega a activarse. -
Curación -- En lugar de establecer la salud directamente, el script aplica Regeneración II durante 2 segundos. Esto funciona de forma más natural con el sistema de salud de Minecraft y se apila correctamente con otros efectos.
-
Cancelación del evento --
context.event:cancel()evita que el clic derecho interactúe con bloques cercanos (colocar bloques, abrir puertas, etc.). -
Retroalimentación de UI --
show_action_bar()informa al jugador del resultado de su acción y las cargas restantes. Mensajes diferentes para el cooldown, el agotamiento de cargas y la curación exitosa hacen que el objeto se sienta responsivo.
Usa /fmm giveitem <id> para obtener un objeto personalizado correctamente etiquetado. Los objetos creados por otros medios pueden carecer de la clave PDC fmm_item_id, lo que significa que los scripts no se activarán para ellos.
Buenas Prácticas
-
Empieza con un hook diminuto y verifica. Escribe un único
on_spawnque envíe un mensaje al log. Confirma que se dispara. Luego construye a partir de ahí. -
Mantén las funciones auxiliares locales. Declara los auxiliares como
local function toggle_door(context)encima de la tabla de retorno. Esto los mantiene fuera del ámbito global. -
Inicializa todo el estado en
on_spawn. Si leescontext.state.is_openenon_right_clickpero nunca lo estableces enon_spawn, seránily tus comparaciones podrían comportarse de forma inesperada. -
Cancela las tareas repetitivas cuando termines. Cada
run_repeatingdebería tener uncancelcorrespondiente enon_destroy. Las tareas filtradas desperdician CPU. -
Usa contextos frescos de callback del scheduler. Los callbacks del scheduler reciben un parámetro de contexto fresco. Usa siempre ese parámetro dentro del callback, no el
contextexterior. -
Mantén
on_game_tickligero. Si defines este hook, se ejecuta en cada tick del servidor (20 veces por segundo). Limita el trabajo costoso detrás decontext.cooldownso un intervalo del scheduler. -
Haz los props invulnerables por defecto. A menos que quieras que el prop sea destruible, incluye la cancelación de daño con
on_left_clicken cada script. -
Usa MAYÚSCULAS para los enums de Bukkit. Los nombres de sonido y de partículas deben usar el formato de constante de enum de Bukkit (p. ej.
"FLAME", no"flame").
Errores Comunes de Principiantes
-
Usar el
contextexterior dentro de un callback del scheduler. El contexto exterior captura una instantánea del momento en que se ejecutó el hook. Dentro de los callbacks, usa siempre el propio parámetro del callback. -
Olvidar cancelar tareas repetitivas. Si inicias un
run_repeatingenon_spawnpero nunca lo cancelas, la tarea se ejecuta hasta que se elimina el prop. -
No inicializar el estado en
on_spawn. Leercontext.state.xantes de establecerlo devuelvenil, lo que puede romper tu lógica silenciosamente. -
Nombres de animación incorrectos. Si
play_animation("open")devuelvefalse, el nombre de la animación no coincide con lo que hay en el archivo del modelo. Comprueba el modelo para conocer los nombres exactos. -
Nombres de sonido/partículas en minúscula.
"flame"no funciona -- usa"FLAME". La API convierte a MAYÚSCULAS internamente para las partículas, pero los nombres de enum Sound deben ser exactos. -
Olvidar
api_version = 1. La tabla devuelta debe incluir este campo, o FMM no cargará el script. -
Poner funciones dentro de la tabla devuelta que no son hooks. Las funciones auxiliares deben declararse encima de la sentencia
return. Solo los nombres de hook (on_spawn,on_right_click, etc.) están permitidos como claves en la tabla devuelta.
Lista de Verificación de Control de Calidad
Usa esta lista de verificación para validar un script de prop antes de desplegarlo:
- El archivo devuelve exactamente una tabla con
api_version = 1. - Cada nombre de hook coincide exactamente con una entrada de la lista de hooks de prop o la lista de hooks de objeto.
context.eventestá protegido conif context.event thenantes de llamar acancel().- Los campos de
context.statese inicializan enon_spawn. - Cada llamada a
scheduler:run_repeating(...)tiene unscheduler:cancel(...)correspondiente enon_destroy. - Los callbacks del scheduler usan el propio parámetro de contexto del callback, no el
contextexterior. - Los hooks
on_game_ticklimitan el trabajo costoso detrás de una comprobación. - Todos los nombres de método existen en la referencia de la API de Props y Objetos -- sin alias inventados.
- Los nombres de sonido y partículas usan nombres de enum de Bukkit en MAYÚSCULAS.
- El script no llama a ninguna operación bloqueante o de larga duración dentro de un hook o callback.
Consejos para la Generación con IA
Si quieres que la IA genere scripts de prop de forma fiable, asegúrate de que el prompt incluya:
- Nombre exacto del hook -- p. ej.,
on_right_click, no "cuando el jugador hace clic en el prop". - Nombres de animación del archivo del modelo -- la IA no puede adivinarlos; proporciónalos.
- Nombres de enum Sound -- p. ej.,
"BLOCK_NOTE_BLOCK_HARP", no "sonido de arpa". - Nombres de enum de partículas -- p. ej.,
"FLAME", no "partículas de fuego". - Si el prop debe ser invulnerable -- si es así, incluye
on_left_clickconcontext.event.cancel(). - Usa solo nombres de método documentados -- si no está en la página de la API de Props, no existe.
Ejemplo de buen prompt
Escribe un script de prop de FMM que reproduzca la animación "activate" al hacer clic derecho, haga el prop invulnerable, genere partículas FLAME en la ubicación del prop al hacer clic, reproduzca el sonido BLOCK_LEVER_CLICK, y tenga un cooldown de 2 segundos entre clics usando context.cooldowns:check_local("activate", 40).
Colección de Objetos Goblin
Hay disponible un conjunto de 10 scripts de objeto de ejemplo con temática de armas goblin como contenido de ejemplo descargable. No vienen incluidos en el jar base del plugin — los únicos scripts que el plugin escribe por sí mismo en scripts/ en la primera ejecución son los cuatro scripts de prop predefinidos: invulnerable.lua, pickupable.lua, storage_single.lua y storage_double.lua. Cada script goblin de abajo demuestra diferentes mecánicas de combate, efectos de partículas y patrones de uso de la API.
| Script | Objeto | Efecto |
|---|---|---|
goblin_golden_sword.lua | Espada de Oro | 25% de probabilidad de barrido -- daña y empuja a todos los enemigos cercanos |
goblin_iron_axe.lua | Hacha de Hierro | 20% de probabilidad de pincho -- lanza al objetivo hacia arriba con efecto visual de dripstone |
goblin_bow.lua | Arco | Atrae hacia el jugador al mob golpeado |
goblin_crossbow.lua | Ballesta | 33% de probabilidad de andanada de fuegos artificiales |
goblin_trident.lua | Tridente | Una cúpula de hielo encierra al objetivo durante 3s |
goblin_iron_hoe.lua | Azada de Hierro | 15% de probabilidad de nubes de veneno |
goblin_mace.lua | Maza | Meteoros caen del cielo (clic derecho) |
goblin_spear.lua | Lanza | Rayo de fuego a lo largo de la mira (shift+clic derecho) |
goblin_shield.lua | Escudo | Cúpula de cristal al bloquear (reactivo) |
goblin_iron_sword.lua | Espada de Hierro | Anillo de oscuridad cargable (shift+clic derecho) |
Estos scripts funcionan con objetos de aspecto vanilla. Cada uno solo necesita una configuración YML con un campo material: -- no se requiere un archivo .bbmodel. Por ejemplo, para habilitar el script de la espada de oro, crea una configuración YML como:
isEnabled: true
material: GOLDEN_SWORD
scripts:
- goblin_golden_sword.lua