Thursday 20 August 2015

Showdown Tech 1: Everything's a Square

Hello again interwebs!

So, when I started this blog, I intended it to be a dev blog for Stratagem (Showdown from here on out), but having restarted the game repeatedly and never having been sure what would definitely work and what would get thrown away, I never really shared the ins and outs of what I was creating. It's time I fixed that. So here begins a multi part series about of few of the design decisions and implementation details of Showdown. It won't cover everything, just the suff I think is worth taking the time to write about.

But first I want to summarize the design of the game so it's clear what I was trying to build. Firstly, Showdown is a twist on a turn based game. Rather than each player taking their turn on their turn the way titles like Fire Emblem or Advance Wars are played, in Showdown, both players make a single command for a subset of their units while their opponent does the same in a manner similar to an asynchronous strategy game like Diplomacy. However, the commands are still resolved sequentially, first one player, then the other. In the original specs, each turn would consist of 3 commands from each player, so when the moves were resolved, the computer would resolve player 1 move 1, then player 2 move 1, p1m2, p2m2, p1m3, and lastly p2m3. Since the player who's move is resolved first gets the benefit of knowing the exact position and stat of each unit at the time of the first move, the resolution order alternates each round, so in the second round would go p2m1, p1m1, p2m2, etc.

This alternating turn order is also the game's only uncertainty beyond the RNG in map generation. A move carried out in the same situation will always have the same result. The variable is always the other player's decision, which can be very predictable if you know your opponent well, and wildly unpredictable if you do not.

Another important component of the original design was a variety of units. There were six units in the paper prototype, 2 melee types, 2 archer types, and 2 cavalry types. Each command would only apply to a single unit. This approach evolved into one where units with similar properties could be given similar orders, ie two archers could be told to attack the same target. Since all units could move, movement orders could be given to all units simultaneously, but the set of available orders differed depending on the unit type. Archers had 3 different kinds of shooting orders while cavalry had no ready order. This was a major point of confusion and led to most players only using one unit at a time. Now for the sake of having a very minimal starting point and finishing before my deadline, I wound up only using one type of unit in the prototype, but the solution I'm about to explain works regardless of the unit type, so it doesn't really matter that all the units are the same variety of archer. The design still assumed that there would eventually be more units in the future.

To encourage more co-ordinated movement, I toyed with the idea of a formation orders, a set of orders used in place of individual orders whenever 2 or more units were being commanded. A vague order would be given to a whole group of units and each would unit would respond in its own way. At first it looked like even more complexity, but then I realized that the formation orders could be the only orders so that each unit was defined by the way it responded to each of the orders. This simplified the game in two ways. Firstly there was now no difference between the controlling one unit and controlling 100; the same order applies regardless of the number of units. Second, the pool of possible orders could be reduced to just a handful. In the version on my website, there are 4: move, attack, ready, and none.

With this view of units, individual unit types and capabilities are not nearly as important as a unit's location on the board. All actions have to be carried out in terms of location relative to the selected unit. Hence the design decision that this blog is focused on, everything's a Square.

What do I mean by this? Let's look at an attack order. In most turn based games, a player selects one unit and gives it an exact target. Often this can combine moving and attacking so that a unit moves into range of its target then attacks the unit directly. Attacks are an interaction between units, with location on the board as a limiting factor. Now try to imagine controlling 2 units at the same time with the same command. Movement is simple enough, both units follow the same path, assuming they have the same movement capacity. But which unit do they attack? They can only attack the same unit if they we close enough at the start that they will both be in range of the same target at the end of their movement. Both could attack the same tile relative to their final position, but this can easily cause one unit to have no target. As an interaction between units doesn't make sense.

In Showdown, actions are performed on squares. An attack order consists of a set of selected units and a direction. There is no movement to consider because movement is in a separate order. Each of the selected units sends an attack in the same direction from its current location. Since the action isn't targeted or specified in terms of one unit, it feels perfectly normal to affect an empty square. We've changed the semantics for the player from "shoot him" to "shoot that way", and made it much easier to resolve the results of an attack ord. The key is that an action is an operation on board locations rather than the units themselves.

Let's talk implementation. A Square in Showdown is a basically just a wrapper for a co-ordinate pair, specifying a single space on a 2D grid. We have horizontal and vertical co-ordinates, and the methods necessary to copy and compare them them.

public struct Square{

    public static Square getNullSquare(){
        return new Square(-1,-1);
    }

    public int x { getprivate set; }
    public int y { getprivate set; }

    public Square(int x,int y) : this(){ this.x = x;
        this.y = y;}

    public override bool Equals(object o)
    {
        return o is Square ? Equals((Square)o) : false;
    }
    
    public bool Equals(Square o)
    {
        return x == o.x &&
            y == o.y;
    }    
}


The important part on the implementation side is the relationship between Squares. This is contained in the  idea of a Direction. A Direction is a like a unit vector, containing the x difference and y difference necessary to move to an adjacent Square. A unit will only move one Square in a cycle, so a distance of several Squares is made one step in a Direction at a time. Let's have a look at Direction:

public class Direction{

    public const int NONE=0;
    public const int NORTHEAST=1;
    public const int EAST=2;
    public const int SOUTHEAST=3;
    public const int SOUTH=4;
    public const int SOUTHWEST=5;
    public const int WEST=6;
    public const int NORTHWEST=7;
    public const int NORTH=8;
    
    private int x;
    private int y;
    
    public Direction(int xint y){
        
        this.x = x;
        this.y = y;
    }

    public bool equals(Direction d){
        return x==d.x && y==d.y;
    }

    public int getX(){
        return x;
    }
    
    public int getY(){
        return y;
    }

    public static Direction getDirection(int d){
        switch(d){
        case NONE:
            return new Direction(0,0);
        case NORTHEAST:
            return new Direction(1,1);
        case EAST:
            return new Direction(1,0);
        case SOUTHEAST:
            return new Direction(1,-1);
        case SOUTH:
            return new Direction(0,-1);
        case SOUTHWEST:
            return new Direction(-1,-1);
        case WEST:
            return new Direction(-1,0);
        case NORTHWEST:
            return new Direction(-1,1);
        case NORTH:
            return new Direction(0,1);
        default:
            return null;
        }
    }

    public static string getDirectionString(int d){
        switch(d){
        case NONE:
            return "NONE";
        case NORTHEAST:
            return "NORTHEAST";
        case EAST:
            return "EAST";
        case SOUTHEAST:
            return "SOUTHEAST";
        case SOUTH:
            return "SOUTH";
        case SOUTHWEST:
            return "SOUTHWEST";
        case WEST:
            return "WEST";
        case NORTHWEST:
            return "NORTHWEST";
        case NORTH:
            return "NORTH";
        default:
            //Debug.Log ("undefined direction detected!");
            return "ERROR: NOT A VALID DIRECTION";
        }
    }

    public 
static int invertDirection(int direction){
        if(direction==0)
            return 0;
        else
            return (((direction - 1)+4)%8)+1;  

    }

    public Direction invertDirection(){
        return new Direction(-x, -y);
    }

}

Each Direction contains an x, y pair, but x and y must each be integers in the interval [-1,1]. This guarantees that when applied to a Square, a Direction will specify the change in x and y to reach one of the 8 Squares adjacent to it.

As a quick note, Direction is often specified as an integer that can later be translated into a full Direction when it's time to use it. These integers can be used as a constants, allowing the constant names to be used for I/O which greatly simplifies both writing and debugging. Another great thing about integers is that they can be used for modular arithmetic. This makes it easy to rotate Directions while they're in integer form. Check out the inversion functions. While we can invert a Direction object using this function:

    public Direction invertDirection(){
        return new Direction(-x, -y);
    }

We can also invert it in interval form by rotating it 4 directions ahead like this:

    public static int invertDirection(int direction){
        if(direction==0)
            return 0;
        else
            return (((direction - 1)+4)%8)+1;  

    }


We just have to make sure we ignore the 0 direction and subtract one. Going nowhere backwards is still going nowhere.

So we have a locations, and a way to find one location relative to another, but if there isn't an actual spot containing a unit, it's all for naught. So let's talk about the Board itself. The Board doesn't actually contain any Squares. Instead, it contains a 2D array of GridSlots where each position is specified by an x,y pair, stored in a Square. Early prototypes used a 2D array of GameObjects, but this was changed for two reasons. The first is tied to into how orders are resolved, which I'll be going into in greater detail in a future post. Basically, partway through order resolution, multiple units need to occupy the same space, so a GridSlot can hold multiple units until each unit has found it's final position for the round.

The second reason is a bit of Unity specific abstraction. All of the scripting functions on a unit are located inside one or more MonoBehaviours. To access a function on a unit, one needs to first retrieve the behaviour through a GetComponent<ScriptName>() call. Since every call to a unit must go through a GridSlot, GridSlot provides a home for all of these calls. If the functions of a unit are moved from one behaviour to another, only GridSlot will need to be modified.

So now we have a Board containing a a 2D array of GridSlots. The location of each GridSlot is specified by a unique Square, and you can define a path from any Square to any other Square by listing a series of Directions. Now let's see how an attack order works.

Once again, an order consists of a list of Squares being given the order, and a Direction in which to apply the attack. To get the Squares that are attacked, we look up the unit at the GridSlot and ask for its range. Then we check the GridSlot one Direction from the source Square and check if the GridSlot contains a target to attack. If it does, we apply the attack to that Square, otherwise we step one Direction from that Square and check again until we either find a target or have taken range steps. Now we just repeat this for each square listed in the order, and we're done.

And there we have it, a system for managing attacks from a set of locations in the same relative direction regardless of what units are involved. Notice that we only ask units if they exist and what their range is. For now, all attacks are a straight line, but future versions can allow the unit to return any pattern based on the Direction.

Now the Square based design wasn't perfect. Remember how the original design was going to have each player make 3 orders? Well people who've tried the prototype are probably wondering why each player only gets one order in practice. This is because Squares are also used for selection. In each order, the Square containing the unit is used as the source for the next movement or attack. When creating an order, a player is only allowed to select a Square that contains a unit because the unit is responsible for displaying selection. This also avoids the possibility of assigning orders to an empty Square and the apparent ability of the player to select opposing units or obstacles in addition to their own units.

The problem arrises when movement is considered over multiple orders. If a unit is supposed to move to a Square, but gets blocked by an opposing unit, then the subsequent orders for the unit will find a different unit, or no unit at all, in the place they expect to apply an order to, so the unit will be left sitting there with it's orders failing or being applied to the wrong unit.

There are two ways to solve this: either orders must track units, or empty squares need to be selectable. I'm not sure which one I prefer for this game, but since both required a considerable overhaul of either the board or the selection system and the UI, I opted to reduce the orders per turn to one per player and kick this problem down the road. One order each is also a better place for a minimum viable product to start from for future iteration. There was nothing magical about 3 orders each and 2 or 4 might be better choices. Anyway, this is the top priority among changes to be made in future versions, if and when I get around to making them.

So that's the first look into how I designed and implemented Showdown. Keep an eye open for more of these. I have a lot I want to talk about, but first I need to Jam. This weekend is Ludum Dare, and hopefully I can give daily updates on whatever I wind up making there.

See you there!

No comments:

Post a Comment