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?


Comments

  • To post a comment, please or register a new account.
Posts Quoted:
Reply
Clear All Quotes