Programming Tutorial

From VASSAL
Jump to: navigation, search

VASSAL's design allows you to write and plug your own Java classes into a module. The core engine of VASSAL is part of an open-source project. The VASSAL engine package contains the source code for the core engine. The VASSAL libraries package contains additional .jar files you will need to compile and run the program. The main class for the application is named org.vassalengine.Main and lives in the VASSAL.jar file.

This tutorial is a continuation of the Board Game Tutorial. All data for this tutorial is in the ZapWars.zip file.

License

The VASSAL engine package is released under terms of the Gnu Library Public License. The compiled code is contained in the Vengine.jar file.

Java VM

VASSAL requires version 1.5 of the Java runtime.

The Module File

A VASSAL module file is an ordinary zip archive. You can open it with any standard zip program. Inside, you will find an "images" directory containing all the graphics used by the module and a file named buildFile which is used by VASSAL to build the module. Any custom classes that you create to use in a module must be added to the zip archive. Simply add the .class files to the archive, with a directory structure mirroring the package structure in the usual way the .jar archives are made. Once the .class files exist in the archive, they can be imported from VASSAL's Configuration Window.

The AbstractConfigurable class

In order to be imported into a VASSAL module, your classes must implement the Configurable interface. The simplest way to do this is to inherit from the AbstractConfigurable class. Refer to the JavaDoc documentation for descriptions of the various abstract methods that your class must implement.

Designing custom VASSAL module components

An example custom component

In this section of the tutorial, we'll add our own customized class to the Zap Wars module that we created in the first part of the tutorial. Suppose Zap Wars requires tracking of a Tension index between the two races. Whenever the Flesh-Eating Zombies dismember a Fuzzy Creature, or a Fuzzy Creature does something annoyingly cute, the tension index rises by a random amount. We'll create a class that tracks the Tension index and provides a button to add a random increment to it. We'll also show how to communicate this action to other players and how to save the results when we save a game.

Our class, Tension.java, will extend AbstractConfigurable. We will have two attributes: "min" for the minimum of the random increment to the Tension index, and "max" for the maximum.

   private int index = 0;
   private int minChange = 0;
   private int maxChange = 0;

The getAttributeNames() and getAttributeValueString() methods are:

   public static final String MIN = "min";
   public static final String MAX = "max";
   
   public String[] getAttributeNames() {
       return new String[]{MIN,MAX};
   }
   
   public String getAttributeValueString(String key) {
       if (MIN.equals(key)) {
           return ""+minChange;
       }
       else if (MAX.equals(key)) {
           return ""+maxChange;
       }
       else {
           return null;
       }
   }

We also must implement the getAttributeDescrptions() and getAttributeTypes() methods:

   public String[] getAttributeDescriptions() {
       return new String[]{"Minimum increment","Maximum increment"};
   }
   
   public Class[] getAttributeTypes() {
       return new Class[]{Integer.class,Integer.class};
   }

The setAttribute() method must be able to accept either a String (the one returned by getAttributeValueString()) or an instance of the corresponding Class in getAttributeTypes() (an Integer):

   public void setAttribute(String key, Object value) {
       if (MIN.equals(key)) {
           if (value instanceof String) {
               minChange = Integer.parseInt((String)value);
           }
           else if (value instanceof Integer) {
               minChange = ((Integer)value).intValue();
           }
       }
       else if (MAX.equals(key)) {
           if (value instanceof String) {
               maxChange = Integer.parseInt((String)value);
           }
           else if (value instanceof Integer) {
               maxChange = ((Integer)value).intValue();
           }
       }
   }

During the initialization of a module, every component's addTo() method is invoked. The argument is the component that appears as its parent in the Configuration Window. This method (rather than the constructor) is where most of the initialization should occur. Our component will use two buttons: one to add a random increment to the Tension index and one to display the current index. These buttons are added to the toolbar of the main controls window in the addTo() method:

   public void addTo(Buildable parent) {
       GameModule mod = (GameModule)parent;
              .
              .
              .
       JButton b = new JButton("Increment");
       b.setAlignmentY(0.0F);
       b.addActionListener(new ActionListener() {
               public void actionPerformed(ActionEvent evt) {
                   incrementButtonPressed();
               }});
       mod.getToolBar().add(b);
       
       b = new JButton("Show total");
       b.setAlignmentY(0.0F);
       b.addActionListener(new ActionListener() {
               public void actionPerformed(ActionEvent evt) {
                   totalButtonPressed();
               }
           });
       mod.getToolBar().add(b);
   }

The removeFrom() method is invoked only when editing a module, if a user deletes it from the Configuration Window. Our removeFrom() method simply undoes what was done in the addTo() method:

   public void removeFrom(Buildable parent) {
       GameModule mod = (GameModule)parent;
              .
              .
              .
       mod.getToolBar().remove(addButton);
       mod.getToolBar().remove(showButton);
   }

Unless you expect other people to be editing your modules, it's also reasonable to do nothing in the removeFrom() method. Likewise, providing a HelpFile is usually not necessary.

   public VASSAL.build.module.documentation.HelpFile getHelpFile() {
       return null;
   }

Since this component will not contain any other components in the Configuration Window, we return an empty list of sub-component types:

   public Class[] getAllowableConfigureComponents() {
       return new Class[0];
   }

The compiled class file must be added to the module zipfile. Open the module with your favorite Zip utility and add the .class files. Be sure to preserve the package structure, i.e. if the fully-qualified class name is "zap.Tension", then the file must be stored under "zap/Tension.class".

Commands, CommandEncoders, and GameComponents

What is the proper event handler for the button? We can increment the tension index in our own instance of the Tension class, but how do we communicate that change to our opponent? The opponent may be connected live via the server or may be reading a logfile that we've written. Both cases are handled in the same way and at the same time.

For any action that can be communicated between two players, we must make a corresponding Command class. Command subclasses my implement the executeCommand() method, which does whatever you want the command to perform, and the myUndoCommand() method, which should return a new Command object that undoes whatever the first Command did. It is valid to return null for myUndoCommand().

For the Tension class, we define an inner Command class that adds an increment to the Tension index

   public static class Incr extends Command {
       private Tension target;
       private int change;
       public Incr(Tension target, int change) {
           this.target = target;
           this.change = change;
       }       
       protected void executeCommand() {
           target.addToIndex(change);
       }
       protected Command myUndoCommand() {
           return new Incr(target,-change);
       }       
       public int getChange() {
           return change;
       }
   }

Once the Command class is defined, we can provide the event handler for the increment button:

   public int newIncrement() {
       return (int) (rand.nextFloat() * (maxChange - minChange + 1))
              + minChange;
   }
   
   private void incrementButtonPressed() {
       GameModule mod = GameModule.getGameModule();
       int change = newIncrement();
       Command c = new
           Chatter.DisplayText(mod.getChatter(),"Tension changes by "+change);
       c.append(new Incr(this,change));
       c.execute();
       mod.sendAndLog(c);
   }

Here, we have used an existing Command, Chatter.DisplayText(), which displays a message in the Chat window. We use the append() method to combine both commands into a single compound Command. The sendAndLog() method sends the Command over the server (if currently connected) and writes it to the current logfile (if one is being written).

The "total" button doesn't actually change the state of the game. It just displays information locally, so its event handler doesn't need to involve any Commands:

   private void totalButtonPressed() {
       GameModule.getGameModule().getChatter().show("Tension index is "+index);
   }

Once we have created a Command, how does VASSAL on the opponent's machine know how to recognize it? A CommandEncoder turns Commands into strings and vice versa. For a Command to be translatable to and from plain text, it must be recognized by a CommandEncoder. We now add methods to the Tension class to implement the CommandEncoder interface.

   public static final String COMMAND_PREFIX = "TENSION:";
   
   public String encode(Command c) {
       if (c instanceof Incr) {
           return COMMAND_PREFIX+((Incr)c).getChange();
       }
       else {
           return null;
       }
   }
   
   public Command decode(String s) {
       if (s.startsWith(COMMAND_PREFIX)) {
           return new Incr(this,Integer.parseInt(s.substring(COMMAND_PREFIX.length())));
       }
       else {
           return null;
       }
   }

Your encoded command should have a unique identifying string at the beginning, but otherwise you are free to encode the necessary data to recreate your Command any way to choose.

In the addTo() method, we need to tell VASSAL to use our class to encode commands:

   public void addTo(Buildable parent) {
       GameModule mod = (GameModule)parent;
       
       mod.getGameModule().addCommandEncoder(this);
       mod.getGameState().addGameComponent(this);
              .
              .
              .
   }

And undo the changes in the removeFrom() method:

   public void removeFrom(Buildable parent) {
       GameModule mod = (GameModule) parent;
       
       mod.removeCommandEncoder(this);
       mod.getGameState().removeGameComponent(this);
              .
              .
              .
   }

Finally, in order to include the Tension index along with other information when saving a game, we must implement the GameComponent interface. The first method is invoked when a game is opened or closed

   public void setup(boolean gameStarting) {
       if (!gameStarting) {
           index = 0;
       }
   }

The second method returns a Command that restores this class to its current state

   public Command getRestoreCommand() {
       return new Incr(this,index);
   }

The full code for the Tension class is included in the "module data" directory within the tutorial directory.

This example should give you an idea of how you can write classes that can do just about anything you like and use them in a VASSAL module. Most classes will probably be added either to the GameModule or to a Map, but because of the extreme flexibility of the framework, just about anything is possible. Go crazy!

Creating custom counters for VASSAL

Nifty though it is, sometimes VASSAL's game piece designer dialog just doesn't do everything you want. In that case, you have the option to write your own Java code that creates your own customized game piece class. In this tutorial, we'll create a game piece that can be magnified by an arbitrary factor.

In almost all cases, the customization can be done by overriding one class, Decorator. Decorator is an abstract class that wraps around a GamePiece and adds functionality to it. In the game piece designer dialog, each "trait" is actually an instance of the Decorator class. We will create an instance of Decorator called Magnifier.java.

Along with implementing a Decorator class, we must create a CommandEncoder that will recognize it. (See "Commands, CommandEncoders, and GameComponents" in the module-coding tutorial.) The class that builds counters is the BasicCommandEncoder class, which only recognizes counters in the VASSAL package. We will create a class MyCounterFactory.java, that recognizes our custom class. Creating our own implementation is very easy:

 public class MyCounterFactory extends BasicCommandEncoder {
   public Decorator createDecorator(String type, GamePiece inner) {
     Decorator piece = null;
     if (type.startsWith(Magnifier.ID)) {
       piece = new Magnifier(type,inner);
     }
     else {
       piece = super.createDecorator(type,inner);
     }
     return piece;
   }
 }

When we add this component to the module (see below), VASSAL will recognize our new counter whenever it appears in a savefile, logfile, or online.

Our constructor must be?

 public Magnifier(String type, GamePiece inner) {
   mySetType(type);
   setInner(inner);
 }

Also, a no-arg constructor is necessary to import the counter into a module with the game piece designer dialog

 public Magnifier() {
   this(ID + "0.5;2.0", null);
 }

The key attributes of a Decorator are its "type" and its "state." The "type" is a string that must begin with a unique character sequence (in order to be recognized by the counter factory). The sequence for the Magnifier class is given by Magnifier.ID. The rest of the "type" contains all information about the piece that is fixed at module-design time. In our case, this is the minimum and maximum magnification factor.

 protected String myGetType() {
   return ID + minMag + ";" + maxMag;
 }

We also have a corresponding mySetType() method:

 public void mySetType(String type) {
   type = type.substring(ID.length);
   int i = type.indexOf(";");
   try {
     minMag = Double.parseDouble(type.substring(0,i));
   }
   catch (NumberFormatException ex) {
     ex.printStackTrace();
   }
   try {
     maxMag = Double.parseDouble(type.substring(i+1));
   }
   catch (NumberFormatException ex) {
     ex.printStackTrace();
   }
 }

The mySetType() method always expects an input argument which is the same as the value returned by myGetType().

The "state" of a piece is information that changes in the course of a game. In the case of the Magnifier class, the state information is the magnification factor:

 protected String myGetState() {
   return "" + mag;
 }
 
 protected void mySetState(String s) {
   try {
     mag = Double.parseDouble(s);
   }
   catch (NumberFormatException ex) {
     ex.printStackTrace();
     mag = 1.0;
   }
 }

The state information changes when the user enters a keyboard command or selects a popup menu item after right-clicking on the counter. We specify what keyboard commands affect the counter with the myKeyEvent method. If the keystroke in the argument matches CTRL-M (the instance variable 'magnifyCommand'), we set the magnification factor. We also must return a Command object that reproduces the change for our opponent:

 public Command myKeyEvent(KeyStroke stroke) {
   Command c = null;
   if (magnifyCommand.equals(stroke)) {
     GamePiece target = Decorator.getOutermost(this);
     String oldState = target.getState();
     // Prompt user for magnification factor
     String s = JOptionPane.showInputDialog("Enter magnification:");
     if (s != null) {
       try {
         mag = Double.valueOf(s).doubleValue();
         mag = Math.max(mag, minMag);
         mag = Math.min(mag, maxMag);
         c = new ChangePiece(target.getId(), oldState, target.getState());
       }
       catch (NumberFormatException ex) {
       }
     }
   }
   return c;
 }

The Command returned is a ChangePiece command. It's important to note that the target of the command is not necessarily the Magnifier instance. Remember that a Decorator is a wrapper around another game piece (specified by getInner()). Each one modifies the game piece that it wraps around. The actual, complete, game piece is the outermost Decorator instance, which is obtainable from the Decorator.getOutermost() method.

We now specify the corresponding command in the popup menu by implementing the myGetKeyCommands() method:

 public KeyCommand[] myGetKeyCommands() {
   if (commands == null) {
     commands = new KeyCommand[]{new KeyCommand("Magnify", magnifyCommand, this)};
   }
   return commands;
 }

Now that we are able set the magnification factor, we can use it when we draw the counter by implementing the draw() method.

 public void draw(Graphics g, int x, int y, Component obs, double zoom) {
   getInner().draw(g, x, y, obs, zoom * mag);
 }    

We must also implement the boundingBox() and getShape() methods to tell VASSAL how large the counter is.

 public Rectangle boundingBox() {
   Rectangle r = piece.boundingBox();
   r.width *= mag;
   r.height *= mag;
   r.translate(-r.width / 2 + piece.boundingBox().width / 2,
               -r.height / 2 + piece.boundingBox().height / 2);
   return r;
 }
 
 public Shape getShape() {
   if (Info.is2dEnabled()) {
     return AffineTransform.getScaleInstance(mag,mag).createTransformedShape(piece.getShape());
   }
   else {
     Rectangle r = piece.getShape().getBounds();
     r.width *= mag;
     r.height *= mag;
     r.x *= mag;  //  Because getShape() returns a shape centered at (0,0), we can simply scale the position as well
     r.y *= mag;
     return r;
   }
 }

At this point, the piece could be used by hand-editing the buildFile, but implementing the EditablePiece interface makes the counter much easier to include into a module. Only a minimal implementation is necessary.

 public String getDescription() {
   return "Magnifyable";
 }
 
 public VASSAL.build.module.documentation.HelpFile getHelpFile() {
   return null;
 }
 
 public PieceEditor getEditor() {
   return new SimplePieceEditor(this);
 }

The getDescription() function returns the trait name that will appear when you import this class. The SimplePieceEditor class prompts the user to supply the "type" and "state" strings when adding this trait to a piece in the module editor.

To configure our module to use this piece:

  1. Use a zip program to add the MyCounterFactory.class and Magnifier.class files to the module. (Remember that the module file is an ordinary zip file, but may have to be renamed to have a .zip extension). Because the classes are declared to be in the 'zap' package, remember to place them in a "zap" folder in the zipfile. For cross-platform compatibility, store the .class files without compression.
  2. Extract the buildFile from the module file and edit it with a text editor. Replace the <VASSAL.build.module.BasicCommandEncoder/> line with one that reads <zap.MyCounterFactory/> This tells the module to use our custom counter factory instead of the default one. Add the buildFile back to the module file.
  3. In the Configuration window, select the "Zombie" Game Piece Prototype Definition and bring up its properties.
  4. Hit the "Import" button and enter the class name, "zap.Magnifier". The Magnifyable trait will appear in the list of available traits. Select it and hit the "Add" button.
  5. By adding the trait to the Prototype, all pieces using that prototype will now automatically have the new trait.