Tutorials/Tutorial 2
Command handlers
So to expand on the previous tutorial, we now want our plugin to actually do more than just sit there and say "i'm here!"
Useful boilerplate code
Before actually starting the tutorial, i will introduce a few useful global variables and functions that you can declare to make things a bit easier for you.
import org.bukkit as bukkit
Since we will use classes residing in the org.bukkit namespace quite often, having it a bit more concise doesn't hurt
server = bukkit.Bukkit.getServer()
As the running server instance is pretty central for a lot of things, this variable lets you access the server more easily
Sending output to console or to players is something that comes up all the time, so let's make that a bit shorter too
from java.util.logging import Level log = server.getLogger() CHAT_PREFIX = "[%s] "%__plugin_name__
This sets the groundwork for prefixing all messages of your plugin with the plugin name, so that the recipient knows where the message came from
def info(*text): log.log(Level.INFO,CHAT_PREFIX+" ".join(map(unicode,text)))
This allows you to easily output stuff to the console, prefixed by your plugin name. you can call it like info(1,2,"foo",bar)
to quickyl print the integers 1 and 2, a string, and some variable. This is not very different from print, but prefixes the message with CHAT_PREFIX
def severe(*text): log.log(Level.SEVERE,CHAT_PREFIX+" ".join(map(unicode,text)))
This allows you to send console output with a different urgency level (prefixed by [SEVERE] on the console instead of the default [INFO])
def msg(recipient,*text): recipient.sendMessage(CHAT_PREFIX+" ".join(map(unicode,text)))
This allows you to quickly send players or the console a prefixed message, for example as a response to a command with msg(sender, "some text",some_variable)
Part 1
Plugin Specification
In the first half of this tutorial, we will continue to work without classes. The plugin we will create will introduce a few simple commands to enable a player to
- illuminate an area by typing
/lettherebelight
while looking at any block - darken an area via
/darkness radius
, extinguishing all torches and glowstone in a sphere around him and setting the world to nighttime - type
/roulette
to spawn a random mob at the players location
The beginning of our plugin, consisting of first the plugin description and then the boilerplate section (that i have in front of basically every plugin i write at the moment) mentioned before, as well as the enable and disable functions, looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | __plugin_name__ = "CommandDemoPlugin_1" __plugin_version__ = "0.1" # boilerplate start import org.bukkit as bukkit server = bukkit.Bukkit.getServer() from java.util.logging import Level log = server.getLogger() CHAT_PREFIX = "[%s] "%__plugin_name__ def info(*text): log.log(Level.INFO,CHAT_PREFIX+" ".join(map(unicode,text))) def severe(*text): log.log(Level.SEVERE,CHAT_PREFIX+" ".join(map(unicode,text))) def msg(recipient,*text): recipient.sendMessage(CHAT_PREFIX+" ".join(map(unicode,text))) # boilerplate end @hook.enable def onEnable(): info("%s v%s enabled"%(__plugin_name__,__plugin_version__)) @hook.disable def onDisable(): info("%s disabled"%__plugin_name__) |
Command handlers
Now, how do you get your plugin to recognize when a command is being used by a player? Of course by using another decorator.
1 2 3 | @hook.command("lettherebelight") def onCommand(sender, args): return True |
The @hook.command
decorator binds a function to a command, whenever a player uses that command, the function is called. Other than the @hook.enable
and @hook.disable
, you can specify arguments for the decorator. In this case, the single argument tells the decorator that it should bind the function to the /lettherebelight
command.
Notice how the function we decorated takes 2 arguments, sender
and args
, the person who used the command (console or a player), and everything that followed the command, split at spaces. So if a player types /lettherebelight foo bar
, sender
is a reference to the player (an instance of the org.bukkit.entity.Player
class), and args
is a tuple looking like ("foo","bar")
(it actually is a java array, but you can use it just like a list)
Light
Now let's do something with our command.
1 2 3 4 5 6 7 8 9 10 11 12 13 | @hook.command("lettherebelight") def onCommand(sender, args): name = sender.getName() if name == "CONSOLE": msg(sender,"I'm not quite sure where you are looking at, so i'm afraid that won't work") else: # get block the player is looking at blocks = sender.getLastTwoTargetBlocks(None,20) # 20 is the maximum range block = blocks[-1] if blocks else None if block is not None: block.getWorld().strikeLightningEffect(block.getLocation()) block.setType(bukkit.Material.GLOWSTONE) return True |
This should be fairly self-explanatory, it checks if the command came from console, and if not, gets the block the player is looking at, replacing it with glowstone and striking a fancy lightning effect for good measure.
You can download the complete source for the current state of the plugin, and give it a try.
Darkness
Now this works fairly well, so lets go about the second command, /darkness
First of all, we rename the previous command handler from onCommand
to something more descriptive like onLightCommand
, and create a second handler onDarknessCommand
. For this new handler, we also specify 2 additional arguments for the decorator, 'usage' and 'desc', the former being the string that bukkit sends a player when the command handler returns False, the latter being the description of the command if you list all commands via help or something else.
1 2 3 | @hook.command("darkness",usage="/<command> radius",desc="Darkens a cubic volume of space around you") def onDarknessCommand(sender, args): return False |
If you add this to your plugin now and try the command /darkness
, it should send you a message with your defined 'usage'
Now lets add the functionality to the command
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | @hook.command("darkness",usage="/<command> radius",desc="Darkens a cubic volume of space around you") def onDarknessCommand(sender, args): try: radius = int(args[0]) except: return False if sender.getName() == "CONSOLE": msg(sender,"Not usable from console") else: # clamp the radius to between 0 and 20 radius = max(0,min(20,radius)) materials = [bukkit.Material.TORCH,bukkit.Material.GLOWSTONE] coordinate_range = range(-radius,radius+1) center = sender.getLocation().getBlock() for z in coordinate_range: for y in coordinate_range: for x in coordinate_range: block = center.getRelative(x,y,z) if block.getType() in materials: block.breakNaturally() center.getWorld().setTime(18000) return True |
The try/except block makes sure the "radius" argument exists, and is an integer literal, and sends the player the proper usage (by returning False) if it isn't
It then checks a cubic volume around the players current location for glowstone and torches, and destroys those, and afterwards sets the world time to midnight.
You can again download the complete source for the current state of the plugin if you didn't write your own version alongside.
Roulette
The final command to implement is the /roulette
command. So without further ado:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import random @hook.command("roulette",usage="/<command>",desc="Try the mob roulette!") def onRoulette(sender, args): spawns = {"Cow":"Moo!", "Chicken":"Cluck Cluck!", "Pig":"Oink!", "Sheep":"Baaah!", "Zombie":"Braaains!", "Skeleton":"...", "Creeper":"That'sss a very nice everything you've got there..."} choice = random.choice(spawns.keys()) sender.getWorld().spawnCreature(sender.getLocation(),bukkit.entity.CreatureType.fromName(choice)) color = bukkit.ChatColor.RED if choice in ["Skeleton","Creeper","Zombie"] else bukkit.ChatColor.GREEN msg(sender,color,spawns[choice]) return True |
Here spawns
contains the mob types that can be spawned as keys, and the message the plugin sends to the player when it spawns that mob as values. It just chooses one at random and spawns it, not much to say about that.
With that, Part 1 is over. You can download the completed plugin here
Part 2
Plugin Specification
Now if you want to do things that are a bit more advanced, sooner or later you have to store some data and have a bit more complicated functions. While you can do a lot of things with flat global functions and global variables, using classes makes things more structured and clean. In this part we will use classes in the plugin, mixed with decorators as before. For this part, you need the modified PythonLoader that is also used for PyDevTools, to be able to mix classes and decorators properly.
For this part, we will do a simple warp plugin. Players should be able to:
- type
/remember
to save their current location without a name - then use
/recall
to teleport to that location - save named locations with
/remember name
- teleport to named locations with
/recall name
- use
/forget name
to delete a saved location
Using a Plugin class
In addition to the header and boilerplate as used before, we need the additional header variable __plugin_mainclass__
since we plan on using a class for our plugin this time. We also need that class to inherit PythonPlugin
and it has to be named exactly like defined in __plugin_mainclass__
. In contrast to before, we now can use onEnable
and onDisable
without a decorator, since they are inherited from PythonPlugin
, but they get the argument self
now since they are bound methods of the plugin instance.
1 2 3 4 5 6 7 8 9 10 11 | __plugin_mainclass__ = "CommandDemoPlugin2" class CommandDemoPlugin2(PythonPlugin): def __init__(self): pass def onEnable(self): info("%s v%s enabled"%(__plugin_name__,__plugin_version__)) def onDisable(self): info("%s disabled"%__plugin_name__) |
Bound methods and decorators
Now lets add the command handlers for our planned commands. The difference when using classes is that you cannot use the normal @hook.command
decorator with instance methods, since they are interpreted as regular (unbound) functions, and the self
argument is not injected into the function call. You can either use the decorator on a global function and then call pyplugin.some_method(sender,args)
from within, or you use the above mentioned modified PythonLoader version and use the alternate decorator @CommandHandler
instead for instance methods (or anything really). The only thing you have to keep in mind for it is that your class has to either be your main plugin class, inheriting PythonPlugin
, or it has to inherit Listener
(which is imported by default), for the decoration of bound methods to work, otherwise it treats them just like normal functions.
This with the regular PythonLoader:
1 2 3 4 5 6 7 | class SomePlugin(PythonPlugin): def some_method(self, sender, args): pass @hook.command("somecommand") def some_method_caller(sender,args): pyplugin.some_method(sender,args) |
or this with the modified PythonLoader (which i will use for tutorials):
1 2 3 4 | class SomePlugin(PythonPlugin): @CommandHandler("somecommand") def some_method(self, sender, args): return True |
Having added our command handlers, our main class now looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | class CommandDemoPlugin2(PythonPlugin): def onEnable(self): info("%s v%s enabled"%(__plugin_name__,__plugin_version__)) def onDisable(self): info("%s disabled"%__plugin_name__) @CommandHandler("remember",usage="/<command> [name]",desc="Saves a location to teleport back to it later") def onRemember(self, sender, args): return True @CommandHandler("forget",usage="/<command> [name]",desc="Removes a previously saved location") def onForget(self, sender, args): return True @CommandHandler("recall",usage="/<command> [name]",desc="Teleports back to a saved location") def onRecall(self, sender, args): return True |
Location saving
We will now add a simple dictionary as storage for saved locations to our class constructor with locations = {}
, and start with fleshing out the onRemember
function.
1 2 3 4 5 6 7 8 9 10 11 12 13 | @CommandHandler("remember",usage="/<command> [name]",desc="Saves a location to teleport back to it later") def onRemember(self, sender, args): if len(args) >= 2: return False elif sender.getName() == "CONSOLE": msg(sender,"Unusable from console") elif not args: self.locations[sender] = sender.getLocation() msg(sender,"Remembered your current location") else: self.locations[args[0]] = sender.getLocation() msg(sender,"Remembered your current location as '%s'"%args[0]) return True |
Usual check for console and proper number of arguments. If no argument is given, it saves the location without a name, so that only this player can /recall
to it later without arguments. If a name is given, it is saved under that name, and any player can later use /recall name
to teleport to it.
Location removing
Now on to onForget
1 2 3 4 5 6 7 8 9 10 11 12 13 | @CommandHandler("forget",usage="/<command> [name]",desc="Removes a previously saved location") def onForget(self, sender, args): if len(args) >= 2: return False name = sender if sender.getName() != "CONSOLE" else None if args: name = args[0] if name in self.locations: del self.locations[name] msg(sender,"Removed location") else: msg(sender,"Found no saved location to remove") return True |
Not a lot to say here, basically just the reverse of onRemember
, with the exception that locations may also be deleted from console.
Teleporting
And the most important part:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @CommandHandler("recall",usage="/<command> [name]",desc="Teleports back to a saved location") def onRecall(self, sender, args): if len(args) >= 2: return False elif sender.getName() == "CONSOLE": msg(sender,"Unusable from console") else: name = args[0] if args else sender if name not in self.locations: msg(sender, "Invalid location") else: sender.teleport(self.locations[name]) msg.sender("Whoosh!") return True |
Teleports the player to the saved location specified, or his personal unnamed location if no arguments are given.
Click here to get the complete source up to this part
Saving & Loading for persistence over server restarts
One thing that is still missing is persistence, since as it is now, if the server is restarted, all locations are lost. For this tutorial i will use simple plaintext storage, as it is enough for that level of complexity. Databases and xml/yaml may be topic of a future tutorial.
So we add 2 methods to save and load our locations, and the respective calls in onEnable
and onDisable
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | from java.io import File # [...] def onEnable(self): self.load() info("%s v%s enabled"%(__plugin_name__,__plugin_version__)) def onDisable(self): self.save() info("%s disabled"%__plugin_name__) def load(self): filepath = File(self.dataFolder,"locations.txt") if filepath.exists(): f = open(filepath.getCanonicalPath()) lines = f.read().split("\n") f.close() for line in lines: name,data = line.split(":") worldname,x,y,z,yaw,pitch = data.split(",") self.locations[name] = bukkit.Location(server.getWorld(worldname),*map(float,(x,y,z,yaw,pitch))) def save(self): lines = [] for key,loc in self.locations.items(): line = "%s:%s,%s,%s,%s,%s,%s"%(key,loc.getWorld().getName(),loc.getX(),loc.getY(),loc.getZ(),loc.getYaw(),loc.getPitch()) lines.append(line) if not self.dataFolder.exists(): self.dataFolder.mkdirs() f = open(File(self.dataFolder,"locations.txt").getCanonicalPath(),"w") f.write("\n".join(lines)) f.close() |
self.dataFolder
is an attribute provided by PythonPlugin
, but keep in mind to only access it in onEnable
and not in __init__
, since it will only be populated after the plugin has fully loaded.
For the saving and loading to work properly even with online players, i also changed the way unnamed locations are saved a bit, from
self.locations[sender] = sender.getLocation()
to
self.locations["~%s"%sender.getName()] = sender.getLocation()
in onRemember
and from
name = args[0] if args else sender
to
name = args[0] if args else "~%s"%sender.getName()
in onRecall
and from
name = sender if sender.getName() != "CONSOLE" else None
to
name = "~%s"%sender.getName() if sender.getName() != "CONSOLE" else None
in onForget
This concludes part 2 of the command tutorial.
source code
Bonus question: How would you go about making /recall
teleport the player to the saved location without changing the direction he is currently looking in, to reduce disorientation?
For the logging at the top , I ended up just using
It works for me :D
Note: As of python loader 0.3.3, the boilerplate code is largely obsolete, and you can just use
log.info(msg)
,log.severe(msg)
andlog.msg(player,msg)
to the same effect as the explicitely defined functions. In addition to that, you can setlog.prefix
to the prefix you would like to use for your messages.server
andbukkit
are also available now without having to do anything special.So it seems like the boiler plate zaph34r uses is outdated... Should I just delete all of the original stuff and replace all of the instances of log, server, and msg with log.info(msg), log.severe(msg), and log.msg(msg)?
@zaph34r : The part about loading yaml config files (which you left for a "future tutorial") I've managed to figure out by myself (basing myself the java api), but I've only managed to load them by manually creating a Configuration object; if I try to use the getConfig() method (the standard way), like this:
I get the following error:
This error is not supposed to happen, except if you do getConfig() before doing onEnable, which is not the case here. Any idea what am I doing wrong?