Since my last post about game scripting I have had the chance to play around more with IronPython. I wrote a quick MUD type demo game that utilizes C# and IronPython on the server and uses Silverlight 2 as the user interface.
Play Cameron's Dungeon!
I wanted to explore this scripting for Perenthia because I want to be able to add dynamic functions or scripts to objects. I wrote this sample to test out using C# as base classes for IronPython classes and passing operations back and forth between the two. As such the source is kind of loose but the concepts are demonstrated.
What I have is a basic framework of C# classes that define an Actor (any object in the game), a Client (a connected player), a Game (the logic of the game) and a Server (for handling HTTP communication, etc.). The Actor, Client and Game classes also server as the base classes for IronPython classes or Creatures, NPCs, Players and Rooms.
Server is the main C# class and gets initialized in the global.asax file like so:
protected void Application_Start(object sender, EventArgs e)
{
SLGameEngine.Server.Start(Server.MapPath("python/startup.py"),
new string[]
{
Server.MapPath("python"),
Server.MapPath("bin"),
System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory()
});
}
Inside the Server class a static class called PyEngine handles all the execution of the IronPython code. The Start method inside the server class makes a few calls into the PyEngine that starts the engine and executes the code in the /python/startup.py script:
public static void Start(string startupFilePath, string[] searchPaths)
{
try
{
PyEngine.Startup(searchPaths);
PyEngine.AddAssembly(typeof(Game).Assembly);
PyEngine.ExecuteFile(startupFilePath);
_timer = new Timer(300000);
_timer.Elapsed += new ElapsedEventHandler(_timer_Elapsed);
_timer.Start();
}
catch (Exception ex)
{
Log.Write(ex.ToString());
}
}
The first two lines initialize the scripting engine while the third actually executes the startup.py script. Inside the startup script is called to create a global instance of the Game class and to start the game running. The timer stuff is used to remove inactive players. After the game is started any input sent from the user to the server is handled by an IHttpHandler class called CommandHandler. This class parses the request and send the input to the Server class that will actually pass the handling of the input to the IronPython Game class.
public void ProcessRequest(HttpContext context)
{
string response = "Invalid Command";
try
{
string cmd = context.Request.Form["cmd"];
string ip = context.Request.UserHostAddress;
if (!String.IsNullOrEmpty(cmd))
{
if (cmd.StartsWith("NEW"))
{
response = Intro.GetIntro(context);
}
else if (cmd.StartsWith("NAME "))
{
string name = cmd.Substring(5);
if (Server.NameExists(name))
{
response = "That name has already been used, please choose another.";
}
else
{
Server.AddClient(ip, name);
response = String.Format("Welcome {0}!{1}{2}", name, Environment.NewLine, Server.GetClientOutput(ip));
}
}
else
{
Server.ProcessInput(ip, cmd);
response = Server.GetClientOutput(ip);
}
}
if (String.IsNullOrEmpty(response))
{
response = "Internal Error";
}
}
catch (Exception ex)
{
Log.Write(ex.ToString());
response = "Internal Error";
}
finally
{
context.Response.ContentType = "text/plain";
context.Response.Write(response);
}
}
The Server class calls methods on the PyEngine class to actually execute IronPython method calls:
public static void ProcessInput(string ipAddress, string input)
{
PyEngine.CallMethod("game", "ProcessInput", ipAddress, input);
}
From PyEngine:
public static void CallMethod(string variableName, string methodName, params object[] args)
{
try
{
ObjectOperations ops = _engine.Operations;
if (_scope.ContainsVariable(variableName))
{
object instance = _scope.GetVariable(variableName);
if (instance != null)
{
if (ops.ContainsMember(instance, methodName))
{
object method = ops.GetMember(instance, methodName);
ops.Call(method, args);
}
}
}
}
catch (Exception ex)
{
Log.Write(ex.ToString());
}
}
The IronPython Game class ProcessInput method:
def ProcessInput(self, ip, input):
words = input.split(' ')
client = self.Clients[ip]
# Update the stored date on the client so it will stay active.
client.LastUpdateDate = DateTime.Now;
# Get the remainder of the words as a string for chat messages.
index = 0
sb = StringBuilder()
for w in words:
if index > 0:
sb.Append(w).Append(" ")
index += 1
message = sb.AppendLine().ToString()
# Setup potential movement realted variables.
prevRoom = client.player.room
exit = -1
direction = ""
if words[0] == "n":
exit = 0
direction = "north"
elif words[0] == "s":
exit = 1
direction = "south"
elif words[0] == "e":
exit = 2
direction = "east"
elif words[0] == "w":
exit = 3
direction = "west"
elif words[0] == "look":
client.Write(ROOMS[client.player.room].ToString(client.player))
elif words[0] == "say":
# Send the say message to all players in the game.
for item in self.Clients:
c = item.Value
if c.player.name != client.player.name:
c.Write(String.Format("{0} says: {1}", client.player.name, message))
elif c.player.name == client.player.name:
client.Write(String.Format("You say: {0}", message))
elif words[0] == "who":
# Print a list of players online.
client.Write("Who's Online:\n")
for item in self.Clients:
client.Write("%s\n"%(item.Value.player.name))
else:
client.Write("\nInvalid Command\n")
if exit > -1:
if ROOMS[client.player.room].exits[exit] > -1:
# Remove the player from the previous room
if prevRoom > -1:
ROOMS[prevRoom].avatars.remove(client.player)
# Add the player to the current room
client.player.room = ROOMS[client.player.room].exits[exit]
ROOMS[client.player.room].avatars.append(client.player)
# Output the resuls to the client
client.Write("You move %s\n"%(direction))
client.Write(ROOMS[client.player.room].ToString(client.player))
else:
client.Write("You can not move in that direction.\n")
The client.Write statements actually pass control back to C# to write the text into a StringBuilder so that all the text can be retrieved at once before sending the response down to the client. On the client side or Silverlight 2 side, input from the user is sent to the server using my HttpHelper class.
private void ExecuteCommand(string cmd)
{
HttpHelper helper = new HttpHelper(new Uri((App.Current as App).ServerUrl), "POST",
new KeyValuePair<string, string>("cmd", cmd));
helper.ResponseComplete += new HttpResponseCompleteEventHandler(helper_ResponseComplete);
helper.Execute();
}
All responses from the server are just output to the chat window:
void helper_ResponseComplete(HttpResponseCompleteEventArgs e)
{
switch (_state)
{
case GameState.NewConnection:
_state = GameState.Name;
break;
case GameState.Name:
_state = GameState.Play;
break;
}
if (e.Response.Equals("That name has already been used, please choose another."))
{
_state = GameState.Name;
}
this.UpdateConsole(e.Response);
}
private delegate void UpdateConsoleDelegate(string text);
private void UpdateConsole(string text)
{
if (this.CheckAccess())
{
_content.Append(text);
txtConsole.Content = _content.ToString() + Environment.NewLine;
double scrollOffset = txtConsole.VerticalOffset;
scrollOffset += txtConsole.ScrollableHeight + txtConsole.ViewportHeight;
txtConsole.ScrollToVerticalOffset(scrollOffset);
}
else
{
this.Dispatcher.BeginInvoke(new UpdateConsoleDelegate(this.UpdateConsole), text);
}
}
The source is not commented very well and lacks consistency because I was trying different things but does lend as a primer and provide something to build from. Of course, this could handle other client interfaces such as AJAX, etc. but I used Silverlight since Perenthia is going to use Silverlight as its UI.
And, because I am good like that, you can download Cameron's Dungeon Source Code (6MB)!
Or just play Cameron's Dungeon!