Saltar al contenido principal

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

  1. Elección del hook -- on_left_click se activa cuando un jugador golpea (clic izquierdo) el prop. Internamente, se trata de un EntityDamageByEntityEvent en el armor stand subyacente del prop.

  2. Verificación del evento -- context.event siempre debería estar presente en este hook, pero la verificación es una buena práctica.

  3. 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

  1. Constantes a nivel de archivo -- OPEN_ANIMATION y CLOSE_ANIMATION se definen encima de la tabla de retorno. Esto facilita cambiarlas para distintos archivos de modelo que puedan usar nombres de animación diferentes.

  2. Inicialización del estado -- on_spawn establece context.state.is_open = false. El estado persiste a través de todos los hooks para esta instancia del prop.

  3. Invulnerabilidad -- El hook on_left_click cancela el daño para que la puerta no pueda romperse accidentalmente.

  4. Lógica de alternancia -- on_right_click comprueba context.state.is_open, detiene cualquier animación en curso, reproduce la animación apropiada, invierte el estado y reproduce un sonido. La llamada a stop_animation() antes de play_animation() asegura transiciones limpias.

  5. 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.

Nombres de animación

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

  1. Constantes -- ZONE_RADIUS y PARTICLE_INTERVAL están a nivel de archivo para facilitar el ajuste.

  2. Inicialización del estado -- on_spawn configura todos los campos de estado a nil / 0 antes de hacer cualquier otra cosa.

  3. 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.

  4. Observación de la zona -- context.zones:watch(handle, function() end, function() end) habilita los hooks on_zone_enter y on_zone_leave. Esos hooks incrementan y decrementan un contador almacenado en context.state; también reciben al jugador que cruza como context.player / context.event.player si quieres un comportamiento específico por jugador.

  5. 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.

  6. Limpieza -- on_destroy cancela 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.

Rendimiento de las partículas

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

  1. 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.

  2. 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.

  3. Retroalimentación de partículas -- Las partículas de nota aparecen sobre el prop cuando se reproduce el sonido, dando una señal visual.

  4. Invulnerabilidad -- El hook on_left_click cancela el daño como de costumbre.

Implementación del cooldown

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

  1. Auto-inicio de la animación -- on_spawn reproduce inmediatamente una animación "idle" en bucle. El false para blend significa que empieza desde cero sin mezclarse con una animación anterior. El true para loop significa que se repite indefinidamente.

  2. 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.

  3. Limpieza -- on_destroy cancela 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

  1. Clic derecho para sentarse -- on_right_click obtiene al jugador de context.event.player, comprueba si ya es un pasajero (para evitar montarlo dos veces), y llama a context.prop:mount(player) para sentarlo en el armor stand del prop.

  2. Clic izquierdo para levantarse -- on_left_click cancela 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.

  3. 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.

  4. 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

  1. Comprobación del objeto -- player:get_held_item() devuelve una tabla con type, amount y display_name para el objeto de la mano principal (o nil si está vacía). held.type es el nombre del material Bukkit en mayúsculas, así que lo comparamos con "GOLD_INGOT".

  2. Consumo del objeto -- player:consume_held_item(1) elimina un objeto de la pila de la mano principal del jugador.

  3. Mejora aleatoria -- La tabla BLESSINGS a nivel de archivo lista los efectos positivos disponibles. math.random(#BLESSINGS) elige uno al azar. player:add_potion_effect(effect, duration, amplifier) lo aplica -- 600 ticks son 30 segundos, el amplificador 1 es nivel II.

  4. 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.

  5. 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

  1. 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.

  2. 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 de player.current_location.

  3. 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.

  4. 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 de PotionEffectType de Bukkit.

  5. 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.

  6. Cooldown -- Se aplica un cooldown de 30 segundos sin importar qué rama se haya tomado, evitando el castigo o la recompensa en ráfaga.

Rendimiento de la generación de entidades

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

  1. Protección de estado -- context.state.is_spinning evita múltiples solicitudes de giro superpuestas. La bandera se establece cuando empieza el giro y se borra cuando se dispara la detención programada.

  2. Animación temporizada -- play_animation(SPIN_ANIMATION, true, false) reproduce la animación una vez (sin bucle). La llamada a scheduler: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.

  3. Sonidos mecánicos -- BLOCK_CHAIN_PLACE da un sonido de inicio mecánico/de clic; BLOCK_CHAIN_FALL da un sonido de parada de desaceleración. Ajusta el tono a tu gusto.

  4. Contexto del callback -- El callback de run_later usa later_context (no context) para todo el acceso a estado y mundo. Este es el patrón correcto para los callbacks del scheduler.

Nombres de animación

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

  1. Zona de proximidad -- context.zones:create_sphere() crea una zona de 3 bloques de radio. context.zones:watch(handle, function() end, nil) habilita on_zone_enter, que se dispara cuando cualquier jugador entra.

  2. Efectos de susto -- El hook on_zone_enter reproduce 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.

  3. 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.

  4. Sin hook de salida -- El tercer argumento nil de context.zones:watch() significa que no nos importa cuándo los jugadores salen de la zona.

  5. Limpieza -- on_destroy deja 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.

Diseño del susto

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

  1. 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 .yml en la carpeta custombosses de EliteMobs.

  2. Fallback elegante -- spawn_elitemobs_boss() devuelve nil si 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.

  3. 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.

  4. Cooldown -- Un cooldown de 10 segundos evita que los jugadores inunden la zona con guerreros goblin. Ajusta COOLDOWN_TICKS según las necesidades de tu jugabilidad.

  5. 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.

Integración con EliteMobs

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

  1. Inicialización del estado -- on_equip establece wave_active = false para evitar múltiples ondas de choque superpuestas. Este hook se dispara cuando el jugador equipa la espada.

  2. 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.

  3. Onda en expansión -- scheduler:run_repeating() se ejecuta cada 2 ticks. En cada tick, el radio de la onda crece según WAVE_SPEED. Las partículas se generan en un anillo, pero solo dentro del ángulo del cono.

  4. 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 tabla destroyed_blocks evita el doble procesamiento.

  5. 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ón if entity.damage then es crítica -- get_nearby_entities() devuelve TODAS las entidades (armor stands, objetos, etc.), no solo las vivientes.

  6. 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.

  7. 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.

  8. Limpieza -- Cuando el radio supera WAVE_MAX_RADIUS, la tarea repetitiva se cancela a sí misma y reinicia la bandera wave_active.

Seguridad del tipo de entidad

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

  1. 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.

  2. Sistema de cargas -- context.item:get_uses() lee un contador de usos personalizado del PDC del objeto. Cada uso lo decrementa con set_uses(). Cuando las cargas llegan a 0, la varita reproduce un sonido de fallo y se niega a activarse.

  3. 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.

  4. Cancelación del evento -- context.event:cancel() evita que el clic derecho interactúe con bloques cercanos (colocar bloques, abrir puertas, etc.).

  5. 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.

Obtener objetos personalizados

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_spawn que 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 lees context.state.is_open en on_right_click pero nunca lo estableces en on_spawn, será nil y tus comparaciones podrían comportarse de forma inesperada.

  • Cancela las tareas repetitivas cuando termines. Cada run_repeating debería tener un cancel correspondiente en on_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 context exterior.

  • Mantén on_game_tick ligero. Si defines este hook, se ejecuta en cada tick del servidor (20 veces por segundo). Limita el trabajo costoso detrás de context.cooldowns o 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_click en 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 context exterior 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_repeating en on_spawn pero nunca lo cancelas, la tarea se ejecuta hasta que se elimina el prop.

  • No inicializar el estado en on_spawn. Leer context.state.x antes de establecerlo devuelve nil, lo que puede romper tu lógica silenciosamente.

  • Nombres de animación incorrectos. Si play_animation("open") devuelve false, 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:

  1. El archivo devuelve exactamente una tabla con api_version = 1.
  2. Cada nombre de hook coincide exactamente con una entrada de la lista de hooks de prop o la lista de hooks de objeto.
  3. context.event está protegido con if context.event then antes de llamar a cancel().
  4. Los campos de context.state se inicializan en on_spawn.
  5. Cada llamada a scheduler:run_repeating(...) tiene un scheduler:cancel(...) correspondiente en on_destroy.
  6. Los callbacks del scheduler usan el propio parámetro de contexto del callback, no el context exterior.
  7. Los hooks on_game_tick limitan el trabajo costoso detrás de una comprobación.
  8. Todos los nombres de método existen en la referencia de la API de Props y Objetos -- sin alias inventados.
  9. Los nombres de sonido y partículas usan nombres de enum de Bukkit en MAYÚSCULAS.
  10. 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_click con context.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.

ScriptObjetoEfecto
goblin_golden_sword.luaEspada de Oro25% de probabilidad de barrido -- daña y empuja a todos los enemigos cercanos
goblin_iron_axe.luaHacha de Hierro20% de probabilidad de pincho -- lanza al objetivo hacia arriba con efecto visual de dripstone
goblin_bow.luaArcoAtrae hacia el jugador al mob golpeado
goblin_crossbow.luaBallesta33% de probabilidad de andanada de fuegos artificiales
goblin_trident.luaTridenteUna cúpula de hielo encierra al objetivo durante 3s
goblin_iron_hoe.luaAzada de Hierro15% de probabilidad de nubes de veneno
goblin_mace.luaMazaMeteoros caen del cielo (clic derecho)
goblin_spear.luaLanzaRayo de fuego a lo largo de la mira (shift+clic derecho)
goblin_shield.luaEscudoCúpula de cristal al bloquear (reactivo)
goblin_iron_sword.luaEspada de HierroAnillo de oscuridad cargable (shift+clic derecho)
No se requiere modelo

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

Próximos Pasos