Sunday, February 24, 2013

Making a custom combat engine

Today, Adam Perry takes a good hard look at what it takes to make a new combat engine in the OHR. As a bonus, there are many example scripts as well as some darn good advice for anyone working on a project of this magnitude. 

Anyone who's hung around any game design forum for any length of time has inevitably seen someone new drift in with a request that looks like this:

"I want to make custom combat engine that is totally awesome and also brews your coffee for you! How do I do it?"

This is awkward. Usually, no one will answer for a day or so, then some kindly soul will step in and gently suggest that the requester start with a smaller, less ambitious project.

But maybe you're past that hump, and you still have no idea how to move mountains with the power of scripting. In this article, I will give you the answer to that age-old newbie question. Are you ready for this?

The answer: Start small.

No, wait. Don't go. It's more than that. But that's the first step. Take a step back and look at the big picture. What do you need your game to do?

Let's go with the example of a turn-based strategy game. Could be tic-tac-toe, chess, or Fire Emblem. What does that engine do at a high level?
  1. The player takes a turn.
  2. If the game isn't over, the computer (or second player, if this is a two-player game) takes a turn.
  3. If the game isn't over, go back to step 1.
  4. Show some appropriate win/lose message.
Can we agree that this is what a turn-based strategy game looks like? Okay, good. (If you're already lost, then maybe you should start with a smaller, less ambitious project.) Let's turn that into a plotscript.

plotscript, my game, begin
   while(game is not over) do (
      player takes a turn
      if (game is not over) then (computer takes a turn)
   )
   show win or lose message
end

Hooray! You have created the skeleton of your fancy engine's script. Does it look familiar? It should – after all, it's almost a word-for-word transcript of the outline above.

But it won't compile, will it? We need to add a few things.

include, plotscr.hsd

script, game is not over, begin
end

script, player takes a turn, begin
end

script, computer takes a turn, begin
end

script, show win or lose message, begin
end

First, you have to include plotscr.hsd. This is necessary in pretty much every plotscript ever. Don't let it distract you; it has no special meaning here.

The rest of the code is empty scripts. In programming parlance, we call these stubs. Stubs are the key to simplifying your scripts. It may seem paradoxical, given that this method will eventually give you a much larger script file, but take a look back at the my game script above, and you'll see that it's much easier to understand what's going on when you break it down like this.

That's all well and good, but now we need to fill in those stubs. Stubs don't do anything by themselves, obviously. Let's start with player takes a turn. In order to write this script, we'll use the same outline method we used earlier. What happens when a player takes a turn?

Well, we need to get more specific at this point. After all, a turn of tic-tac-toe doesn't resemble a turn of chess or Fire Emblem. The process is the same no matter which one we pick. Let's go with chess, for the sake of familiarity and a game that you might actually play. Don't worry if you don't know all the rules of chess; that's not important for this example.
  1. The player selects a piece.
  2. The player selects a destination for that piece and moves it to another space on the board.
Of course, there's more to it than that, but we want to stay at a high level for now. That means that we won't worry about whether the player's move is legal for the time being, and we'll similarly worry about knocking out enemy pieces later. (Similarly, we're ignoring the possibility of the player changing his mind. In a real game, you would want to provide this option.)

So let's put that in script form again.

script, player takes a turn, begin
   variable(piece)
   piece := select a piece
   select destination for piece(piece)
end

script, select a piece, begin
end

script, select destination for piece, which piece, begin
end

Now, this is a little more complicated than my game. But not by much! Bear with me.

First, we have a variable. That's a way for the script to remember something; in this case, which piece the player picked. That variable is later fed into the destination script as an argument. An argument gives a script some context by allowing you to feed it some information.

Now, perhaps you knew all that, and maybe you're thinking, "More stubs! This really isn't going anywhere. It's just stubs all the way down." Calm down; we're actually getting somewhere here.

We have two new scripts. First, we need to figure out a way to allow the player to select a piece. We'll ignore it for now, but remember this for later.

Instead, let's concentrate on the second script. We described it in our outline like this: "The player selects a destination for that piece and moves it to another space on the board." How does that actually work in outline form?
  1. The player selects a square on the board.
  2. If the player's selection is not a legal move for the selected piece, then go back to step 1.
  3. If there is already a piece in the destination, then remove it from the game.
  4. Finally, move the piece to the destination square.
If you're keeping up with me here, then you can probably guess how this will look. I'll show it in a bit, but I want to emphasize something: this is the hard part. That's right: coming up with these outlines is the hardest part of programming something complex. Consider the following outline:
  1. The player moves a piece.
  2. The computer moves a piece.
  3. Go back to step 1 until the game is over.
This is also an accurate, high-level description of chess, and at a glance, it looks a lot like our first outline. But it's actually an easy trap to fall into that will make your life much harder. What's the difference?

The smaller issue is that it's actually incorrect. If the player puts the computer into checkmate, then the computer shouldn't get another turn. Of course, you'll easily realize the mistake after any amount of testing, but by that point, you've already lost one of the principal advantages of using this method: once you write a high-level script, you're done with it. You should never need to touch my game or player takes a turn at this point. If you do, it's because you forgot something in the outline phase.

The larger issue is that a less granular outline heavily encourages you to make bulkier scripts, which are harder to read and are more likely to need changes later on (which is a problem, since they're harder to read). Consider the following (mostly unmodified) script from Super Penguin Chef (minor spoilers):

plotscript, goodnight, begin
   variable(i)
   for(i, 0, 3) do (
      # Restore HP/MP
      set hero stat(i, 0, get hero stat(i, 0, maximum stat), current stat)
      set hero stat(i, 1, get hero stat(i, 1, maximum stat), current stat)
   )
   # Clear adventuring tags
   set tag(5, off) # adventured
   set tag(6, off) # beat up
   set tag(11, off) # restauranted
   
   # Special evening events
   switch(game day) do (
      case(1) do (
         show text box(227)
         set tag(7, on) # day two
      )
      case(4) do (payment due := 500)
      case(9) do (payment due := 2000)
      case(14) do (payment due := 7500)
      case(19) do (payment due := 12500)
      case(24) do (payment due := 25000)
      case(29) do (payment due := 0)
   )
   
   wait for text box
   fade screen out
   wait(20)
   game day += 1
   total days += 1
   switch(game day, mod, 5) do (
      case(1) do ($string:temp = "Wednesday")
      case(2) do ($string:temp = "Thursday")
      case(3) do ($string:temp = "Friday")
      case(4) do ($string:temp = "Monday")
      case(0) do ($string:temp = "Tuesday")
   )
   clear string(1)
   clear string(2)
   if (payment due) then ($1 = "${V9} Moneys due next Monday!")
   if (game day, mod, 5 == 3) then (
      $1 = "There's a contest today!"
      if (payment due) then ($2 = "${V9} Moneys due tomorrow!")
   )
   if (game day, mod, 5 == 4 && payment due) then (
      $1 = "${V9} Moneys due tonight!"
      set tag(17, on)
   )
   expand string(1)
   expand string(2)
   
   # Figure out a starting map - usually home
   switch(game day) do (
      case(2) do (
         teleport to map(2, 11, 9)
      )
      case(3) do (
         teleport to map(2, 11, 9)
         create npc(2, 10, 4, right)
         set npc direction(0, left)
      )
      else (
         teleport to map(1, 8, 6)
      )
   )
   
   suspend player
   show text box(222)
   wait(5)
   fade screen in
   wait for text box
   
   # Special morning events
   switch(game day) do (
      case(2) do (
         talk to bruschetta(157)
      )
      case(3) do (
         talk to bruschetta(279)
         set npc direction(0, down)
         walk npc(2, down, 4)
         wait for npc(2)
         walk npc(2, right)
         wait for npc(2)
         walk npc(2, down, 2)
         wait for npc(2)
         destroy npc(2)
         show text box(302)
      )
      case(4) do (
         show text box(276)
      )
   )
   wait for text box
   resume player
end

Did your eyes glaze over? Maybe that's because there's way too much going on in that script. If I find out that there's a bug that's causing the player to wake up in the wrong spot, then I have a lot of script to go through before I can find it. Here's a version of that script that's much easier to read:

plotscript, goodnight, begin
   heal party
   clear adventuring tags
   special evening events
   fade screen out
   wait(20)
   game day += 1
   total days += 1
   reposition party for start of day
   suspend player
   show new day text box
   wait(5)
   fade screen in
   wait for text box
   special morning events
end

That's much shorter. Now, if I find out there's a bug that's causing the player to wake up in the wrong spot, then I know to look in the reposition party for start of day script. The actual functionality hasn't changed, and everything I removed exists somewhere else, but now it's very easy to see what happens when the player goes to bed for the night.

With that in mind, let's go back to the select destination for piece script. Here's our outline, in case you've forgotten:
  1. The player selects a square on the board.
  2. If the player's selection is not a legal move for the selected piece, then go back to step 1.
  3. If there is already a piece in the destination, then remove it from the game.
  4. Finally, move the piece to the destination square.
And here's the script:

script, select destination for piece, which piece, begin
   variable(destination)
   destination := select a square
   while (piece can move to square(which piece, destination) == false) do (
      destination := select a square
   )
   remove piece from square(destination)
   move piece to square(which piece, destination)
end

script, select a square, begin
end

script, piece can move to square, piece, square, begin
end

script, remove piece from square, square, begin
end

script, move piece to square, piece, square, begin
end

And now we've stumbled on another advantage of this scripting method: you can reuse your scripts. Watch how we can create some scripts to fill in three of our existing stubs.

script, select a piece, begin
   variable(piece, square)
   while(piece == false) do (
      square := select a square
      piece := piece on square(square)
   )
   return(piece)
   # TODO: Make sure this piece belongs to the player
end

script, remove piece from square, square, begin
   variable(npc)
   npc := piece on square(square)
   if (npc <> false) then (destroy npc(npc))
end

script, move piece to square, piece, square, begin
   walk npc to x(piece, square x(square))
   walk npc to y(piece, square y(square))
   wait for npc(piece)
end

script, piece on square, square, begin
   # Returns the NPC ID of the piece at the given location
   return(npc at spot(square x(square), square y(square)))
end

script, square x, square, begin
   # Given a square, returns its X coordinate
end

script, square y, square, begin
   # Given a square, returns its Y coordinate
end

We've managed to reuse select a square, as well as the new scripts piece on square and square x/y. Even better, we have scripts that actually do something now. Once you get down to a low enough level, you're using the built-in plotscripting commands to actually interact with the game, but in small, manageable chunks. There's nothing confusing about the move piece to square script, and we'd be able to reuse it when it came time to do the computer takes a turn script.

Which we won't, at least not in this article. If you're going to make a chess game, make it two-player. (After all, you should start with a smaller, less ambitious project.)

And this is where we'll stop. Now, you don't magically know how to make your super combat engine that brews your coffee for you. But what you should know after reading this article is how to turn this:

"How do I make a custom battle engine?"

...into this:

"I'm making a custom battle engine and I'm trying to figure out how to determine if the hero is touching the enemy. How do I do that?"

And I can guarantee you'll get a better response with the second question. Ask enough of those, and you will know how to write that script. And when you do, you owe me a coffee.

No comments:

Post a Comment