Scripting
You implement game scripting in a few different ways, depending on what goals you're trying to achieve. Here are some options, along with pros and cons:
- Compiled Java. Using compiled classes has a speed advantage and is easily embedded, but the level designer must download the Java SDK and know how to create classes, set the classpath, and compile. Also, sometimes creating entire classes just to call door1.open() is overkill.
- Your own command interpreter. Writing simple commands such as door1.open is easy, but this solution isn't very flexible because it limits what you can do. Plus, you have to write code to interpret the different commands.
- Use a scripting language. Using a scripting language is easy for casual users and gives them the flexibility they need. The right scripting language can even access live Java objects directly. The drawback is that scripts are slower than compiled code and you need a separate interpreter.
In this chapter, we use a scripting language. Besides the stated advantages, scripting is great for rapid development when compared to compiled classes because you can just edit a file and run, skipping the compile stage. Also, you can add a command to dynamically update scripts at runtime, which is great for in-game level designing and debugging. The drawback is that scripted code won't run as fast as Java code because it's interpreted at runtime. However, usually scripts call only a few methods here and there, and the computationally expensive tasks are kept in the Java code. For complete mods, you might need both compiled and scripted code. Many scripting language interpreters are available for Java. For example, BeanShell interprets Java-like code. Other interpreters include Rhino (JavaScript) and Jython (Python). There are also interpreters for other languages, such as Tcl, Scheme, Smalltalk, and Visual Basic. When you're choosing a scripting language, be sure to pick one that can directly access live Java objects from scripts. Connecting with live Java objects is key to game scripting-with this functionality, scripts can call the methods of any live object, so you don't have to provide any sort of communication layer between scripts and actual code. Scripts can directly access live Java objects through the use of the Java Reflection API. Reflection allows you to invoke a method of an object just by knowing the name of the method. Embedding a scripting language interpreter is fairly easy and generally requires only a few method calls. In this chapter, you'll embed BeanShell, which is a free Java interpreter that can easily connect with live Java objects. BeanShell code is similar to Java code but is more flexible. BeanShell is loosely typed, so coders don't have to do any casting or declare the type of any variables. Plus, it's fairly small, running at about 125KB for the core interpreter. More information on BeanShell can be found at www.beanshell.org. You need to perform two steps to implement scripting:
- Design how the scripts are called. What does the level designer need to write?
- Embed the interpreter. How do you call scripting functions?
In other words, look at this from the point of view of what you want to accomplish, and then implement it. First things first, right? Let's start on designing the scripts.
Designing the Script
First, you want to avoid requiring level designers to create classes and interfaces. Simple functions will do just fine. Second, you don't want to make it cumbersome to get access to game objects. You want to avoid writing code like this:
openDoor(gameObjectManager.getObject("door1"));
This code retrieves the game object named door1 from the game object manager. Instead, it would be nice if you could refer to game objects as if they were variables, like this:
openDoor(door1);
Here's a look at what you might do in Listing 14.7. Note that BeanShell files end in .bsh.
Listing 14.7 level1.bsh
/** Functions to open and close the door. */ player_doorTriggerTouch() { // move the door up moveDoor(180); } player_doorTriggerRelease() { // move the door down moveDoor(0); } moveDoor(int y) { // move all seven pieces of the door speed = 0.5f; moveYTo(door1a, y, speed); moveYTo(door1b, y, speed); moveYTo(door1c, y, speed); moveYTo(door1d, y, speed); moveYTo(door1e, y, speed); moveYTo(door1f, y, speed); moveYTo(door1g, y, speed); }
In the map for this script are seven objects that represent the door. These objects look like tall poles, so the door resembles a gate. Also, there is an invisible trigger area called doorTrigger. This script provides two functions: player_doorTriggerTouch(), which opens the door, and player_doorTriggerRelease(), which closes it. The variables named door1a, door1b, and so on refer to game objects defined in the map file. This is pretty simple: There are only a couple of functions, and it doesn't need functions for notifications that aren't used in this case, such as wall collisions. You'll follow this naming convention for the function names in your scripts. Each function starts with the name of the object that receives the notification. For example, the methods for collision notifications for the player object are listed here:
playerFloorCollision() playerWallCollision() playerCeilingCollision()
Likewise, if the player object collides with the box object, these methods are called:
player_boxCollision() player_boxTouch() player_boxRelease()
Of course, these functions don't have to be defined in the script. One problem arises when implementing doors this way: AI bots currently check only the BSP tree for vision tests, not game objects. This means that bots can "see" right through doors, finding players on the other side of the door or not considering a door during path finding. Bots would probably attempt to move through or shoot through doors to get to the player. You could fix this by having the door covered by a fake, invisible polygon that acts as a vision barrier to the robots. Note that the moveYTo() method in level1.bsh isn't defined yet. You can create a BeanShell file called main.bsh that defines common functions that will be helpful in scripts for each level. This script is in Listing 14.8. At the moment, only the moveYTo() method is defined.
Listing 14.8 main.bsh
// useful imports import com.brackeen.javagamebook.game.*; import com.brackeen.javagamebook.math3D.*; import com.brackeen.javagamebook.util.*; import com.brackeen.javagamebook.path.*; moveYTo(GameObject object, int y, float speed) { object.setFlying(true); loc = new Vector3D(object.getLocation()); loc.y = y; object.getTransform().moveTo(loc, speed); }
BeanShell imports packages just like Java, so a few packages have been imported here to make it easy. You can use main.bsh as a place to put many methods to make the BeanShell scripts for the game easier to write. Now you've designed how your scripts are written and even have a sample script. Next, you'll embed the BeanShell interpreter into your code.
Embedding BeanShell
Embedding BeanShell or any other interpreter generally will not take a lot of work. For BeanShell, first create a BeanShell Interpreter object, which is in the .bsh package:
Interpreter bsh = new Interpreter();
You can use the eval() method of the interpreter to evaluate any Java expression, like this:
bsh.eval("System.out.println(5+3)");
Also, you can set variables with the set() method:
bsh.set("a", myObject);
Finally, you can load entire BeanShell scripts with the source() method:
bsh.source("level1.bsh");
This is all you really need, but be sure to check out the complete API documentation for BeanShell at www.beanshell.org. We sum up all the BeanShell methods in the ScriptManager class here in Listing 14.9.
Listing 14.9 ScriptManager.java
package com.brackeen.javagamebook.scripting; import java.io.IOException; import java.util.*; import com.brackeen.javagamebook.game.*; import bsh.*; public class ScriptManager { private static final Class[] NO_ARGS = new Class[0]; private Interpreter bsh; private GameObjectEventListener scriptedListener; public ScriptManager() { scriptedListener = new ScriptedListener(); } /** Sets up the ScriptManager for a level. The list of script files are executed, and every object in the GameObjectManager that has a name is added as a named variable for the scripts. Also, the scripted method initLevel() is called if it exists. */ public void setupLevel(GameObjectManager gameObjectManager, GameTaskManager gameTaskManager, String[] scriptFiles) { bsh = new Interpreter(); try { // execute source files (and load methods). for (int i=0; i<scriptFiles.length; i++) { bsh.source(scriptFiles[i]); } bsh.set("gameTaskManager", gameTaskManager); // Treat all named objects as named variables in // beanshell. // NOTE: this keeps all objects in memory (live) until // the next level, even if they are destroyed during the // level gameplay. Iterator i = gameObjectManager.iterator(); while (i.hasNext()) { GameObject object = (GameObject)i.next(); if (object.getName() != null) { bsh.set(object.getName(), object); // add scripted listener to object if (hasScripts(object)) { object.addListener(scriptedListener); } } } // init level code - call initLevel() invoke("initLevel"); } catch (IOException ex) { ex.printStackTrace(); } catch (EvalError ex) { ex.printStackTrace(); } } /** Checks to see if the specified game object has any scripts (check to see if any scripted method starts with the object's name). */ public boolean hasScripts(GameObject object) { if (object.getName() != null) { String[] names = bsh.getNameSpace().getMethodNames(); for (int i=0; i<names.length; i++) { if (names[i].startsWith(object.getName())) { return true; } } } // none found return false; } /** Returns true if the specified method name is an existing scripted method. */ public boolean isMethod(String methodName) { return (bsh.getNameSpace(). getMethod(methodName, NO_ARGS) != null); } /** Invokes the specified scripted method. */ public void invoke(String methodName) { if (isMethod(methodName)) { try { bsh.eval(methodName + "()"); } catch (EvalError e) { e.printStackTrace(); } } } }
In this code, the setUpLevel() method loads the source BeanShell files (you'll want to load main.bsh and level1.bsh) and sets the gameTaskManager (which we talk about in the next section). Also, it uses the set() method to set variables for each game object, so you can easily refer to each game object by name (as in door1). Next, it checks to see if a particular object has any scripts; if so, it adds the ScriptedListener to it (we talk about that next). A game object has scripts if any functions exist that start with the name of the game object. Finally, it calls the initLevel() script function to perform any code necessary to initiate the level. A call to isMethod() returns true if the specified name is a no-argument method in the script. Note that evaluating an expression in BeanShell or loading a BeanShell script can cause an EvalError to be thrown. This can happen if there is a problem with the BeanShell script and the error message will help with debugging. In this code, we just print the error to the console. The ScriptedListner class is, of course, a game object event listener that calls BeanShell scripts based on your function-naming convention. It's added as a listener only to game objects that have scripts. The code is listed here in Listing 14.10.
Listing 14.10 ScriptedListener Inner Class of ScriptManager.java
/** A GameObjectEventListener that delegates calls to scripted methods. A ScriptedListener is added to every GameObject that has at least one scripted method. */ public class ScriptedListener implements GameObjectEventListener { public void notifyVisible(GameObject object, boolean visible) { invoke(object.getName() + (visible?"Visible":"NotVisible")); } public void notifyObjectCollision(GameObject object, GameObject otherObject) { if (otherObject.getName() != null) { invoke(object.getName() + "_" + otherObject.getName() + "Collision"); } } public void notifyObjectTouch(GameObject object, GameObject otherObject) { if (otherObject.getName() != null) { invoke(object.getName() + "_" + otherObject.getName() + "Touch"); } } public void notifyObjectRelease(GameObject object, GameObject otherObject) { if (otherObject.getName() != null) { invoke(object.getName() + "_" + otherObject.getName() + "Release"); } } public void notifyFloorCollision(GameObject object) { invoke(object.getName() + "FloorCollision"); } public void notifyCeilingCollision(GameObject object) { invoke(object.getName() +"CeilingCollision"); } public void notifyWallCollision(GameObject object) { invoke(object.getName() + "WallCollision"); } }
This Class is an inner class of ScriptManager and calls the invoke() method of ScriptManager to invoke the script functions. That's all you need for scripting. Here's an example of how it breaks down, if you're a little fuzzy:
- The player touches switch1.
- Collision-detection code gets the player's listener and dispatches the touch event.
- One of the listeners is a ScriptListener, which calls the appropriate touch function in the script.
One final note, however, is that you should be wary of the problems from obfuscating code. Obfuscation, which we talk about in , "Game Design and the Last 10%," mangles class, method, and field names so that they are harder to read when decompiled. This causes problems with scripts that are trying to call certain methods because the name of the method is different in obfuscated code. So, if you obfuscate your code, be sure to leave public APIs alone (keep public method names) and obfuscate only internal, private code. This way, the scripts will still work.