Three Hundred :: Mechanic #166
  Squidi.net Sites:     Webcomics   |   Three Hundred Mechanics   |   Three Hundred Prototypes
 Three Hundred
   - Index Page
   - About...
   - By Year

 Collections
   - Comp-Grid
   - Procedural
   - Tactics
   - Tiny Crawl
   - Misc


 
PreviousMechanic #166Next

  Mechanic #166 - PGC: View-Tile Rulesets
Posted: Oct 20, 2012

A technique to transform logical tile data into pretty visual tiles through a simple set of rules.


  View-Tile Rulesets

This entry is based on material published in the
Blind Mapmaker in October 2009.

 

[tiles1.png]

Fig 166.1 - Two pictures of the same thing.

This is a simple technique to take the tile data on the left side and produce new tile data, as seen on the right side. The primary purpose is to define levels at a logical level and then, through the use of a rule set, procedurally generate a visual representation of that logical data in an aesthetically pleasing manner. That is, you take the data on the left side, pass it through a translation process, and produce the data on the right.

The reason why this is useful is because procedurally generating levels tends to happen at the logical level. You care whether a tile is a wall, but you aren't going to be spending a lot of effort trying to figure out whether that wall has a shadow being cast on it or whether it connects gracefully to the wall next to it. Here, you can concentrate on gameplay data, but you can make it pretty later.

This is made possible by separating the model of the game level from the view of the game level (as per Model-View-Controller). The controller is the process which translates one to the other. Because of this separation of model and view, you can use any number of views without having to change how the model data is stored or used. For instance, by changing the controller, you can use the same level data to produce a lava filled cave with obsidian walls, or a peaceful forest with lazy rivers flowing through it. Heck, even the ASCII version of the level on the left is but one possible view of the model.


  One Rule To Rule Them All

To make things simple, I'm going to talk about the model data as if it were straight up ASCII characters. This is a terrible way to represent your level data, but it makes it easier to illustrate. The model level data is simply a two dimensional array of ASCII characters. The view is a two dimensional array of the same size, where each cell is an index into an array of tile images.

 

[tiles2.png]

Fig 166.2 - A sample rule.

This system is based on a series of rules that are organized by the particular cell type you are examining (i.e. you would have a WALL tile rules and FLOOR tile rules, etc). Each rule will attempt to match the eight neighboring cells, and should the match succeed, will return an integer index into the tile images.

There are three basic match types for each neighbor:

  • Match a specific type of tile type.
  • Match anything EXCEPT a specific tile type.
  • Match anything.
  • In many cases, you'll want specific rules that match a very narrow set of circumstances, and broad rules which cover a large variety of cases. It is possible for the same tile configuration to match multiple rules at one time, so it is important to run the rules starting with the specific and working towards the broad. As such, I recommend that the rules be run in the linear order that they are defined and making it the responsibility of the rule creator to ensure specific rules come before broad ones.

     

    Sample Ruleset
    type: {
        empty: {
            typeStr: "empty",
            defaultTileIndex: 0,
            rules: [],
        },
            
        floor: {
            typeStr: "floor",
            defaultTileIndex: 1,
            rules: [
                { tileIndex: 33, map: { w:"ceiling", nw:"NIL" } },
                { tileIndex: 33, map: { w:"ceiling", sw:"NIL" } },
                { tileIndex: 49, map: { w:"wall", sw:"floor" } },
                { tileIndex: 17, map: { w:"ceiling", nw:"floor" } },
                { tileIndex: 33, map: { w:"wall" } },
                { tileIndex: 33, map: { w:"ceiling" } },
            ],
        },
            
        wall: {
            typeStr: "wall",
            defaultTileIndex: 2,
            rules: [
                { tileIndex: 2, map: { w:"wall", e:"wall" } },
                { tileIndex: 2, map: { w:"wall", e:"ceiling" } },
                    
                { tileIndex: 4, map: { w:"floor", e:"wall" } },
                { tileIndex: 4, map: { w:"floor", e:"ceiling" } },
                { tileIndex: 5, map: { w:"wall", e:"floor" } },
                { tileIndex: 5, map: { w:"ceiling", e:"floor" } },
                    
                    
                { tileIndex: 18, map: { w:"ceiling", n:"ceiling", e:"wall" } },
                { tileIndex: 18, map: { w:"ceiling", n:"ceiling", e:"ceiling" } },
                { tileIndex: 19, map: { w:"ceiling", n:"ceiling", e:"floor" } },
                    
                { tileIndex: 34, map: { w:"ceiling", n:"wall", e:"wall" } },
                { tileIndex: 35, map: { w:"ceiling", n:"wall", e:"floor"} },
                    
                    
                { tileIndex: 19, map: { w:"ceiling", n:"ceiling", e:"!wall" } },
                { tileIndex: 35, map: { w:"ceiling", n:"wall", e:"!wall" } },
                { tileIndex: 4, map: { w:"!wall", e:"wall" } },
                { tileIndex: 5, map: { w:"wall", e:"!wall" } },
                { tileIndex: 3, map: { w:"!wall", e:"!wall" } },
            ]
        }
    }
    

    Here's a simple set of rules as I defined them in JavaScript. Each rule has a tileIndex and a map of tiles to match (named after the compass direction). Each map cell defaults to match anything, so you only need to define the cells that must match a specific tile or anything but a specific tile. Each set of rules is organized by tile type which also includes other data, such as the name of the tile type (for matching reference) and a defaultTileIndex in case none of the rules match.

    Notice that in the wall ruleset, there are several rules which will match the same circumstances:

    { tileIndex: 19, map: { w:"ceiling", n:"ceiling", e:"!wall" } },
    { tileIndex: 35, map: { w:"ceiling", n:"wall", e:"!wall" } },
    { tileIndex: 3, map: { w:"!wall", e:"!wall" } },


      The Edge of the Map

    After you've implemented the system, you may realize that there is a small problem that was initially overlooked - the edges of the map. Depending on how you write your mapping functions, grabbing a tile off the edge of the map may return a default value, a nil value, or just throw an exception. Whatever the case, you may notice that none of your border tiles are matching any rules (or the right rules) and producing the wrong tile values.

    There's two simple solutions here. The first is to just make your maps a little bit larger than you can see, so that the border tiles aren't visible - though this is wasteful. The other solution is that off the map cells return a nil value, and then to include NIL as a possible rule in the same way that ANY is a rule. NIL will only match tiles off the map, allowing you to create rules specifically for border tiles.

     

    [tiles3.png]

    Fig 166.3 - Here there be dragons.

    Problem with that is that it'll require a lot of rules for what are ultimately limited special cases. A more practical solution is to sort of combine the two. Rather than having two extra rows and two extra columns, just mirror the nearest like row or column. For instance, when querying the type at (-1, 14), just return the type at (0, 14). (-1, -1) becomes (0, 0). 99% of the time, you can create perfect border tiles this way.


      Poser Tiles

    Occasionally, you'll have logical tiles that are specialized versions of more broad categories. For instance, a cracked floor is, for all intents and purposes, a floor tile type. When checking rules for floor tiles, you want cracked floors to match floor rules. To do this, tiles can have a list of tiles that it can "pose" as.

    Basically, instead of checking a rule against a tile type directly, instead ask the tile if it matches the rule and let the tile type decide whether or not it matches. This allows you to treat cracked floors as regular floors for the purposes of finding the view tiles for its neighbors.


      Random Tiles

    You can mix things up by adding a list of tile indexes that a rule evaluates to instead of a single value. You can use this to introduce random variation. For instance, if you have a field of flowers, it might be represented internally as a single FIELD tile type. However, to give it a more organic feel, you might use randomization to insert different varieties of flowers and plants to the field.  

    Using a list of tile indexes for random variation
    type: {
    
        field: {
            typeStr: "field",
            defaultTileIndex: [1, 4, 6, 3],
            rules: [],
        }
    
    }
    


      Insular Patterns

    There are a few common patterns that will repeatedly crop up that you can save some time and energy by standardizing the rules. There are three patterns that I've noticed, and all three of them rely on each type being insular. That is, the only types it cares about are itself and NOT itself. A ceiling doesn't care whether it is next to a lava pit or a floor, only that it isn't next to another ceiling.

    The idea is that each pattern can build a specific set of rules if you pass it the needed tiles in a specific order. So if the Rug pattern requires 16 tiles, you can declare a ruleset a type of Rug and only supply those 16 integers that correspond to corners and whatnot. You can then add additional rules to override them if you choose (for instance, to introduce random variations).

     

    [rug.png]

    Fig 166.4 - Rug pattern.

    The Rug is a pattern which can be used to create any rectangular pattern down to a 1x1 rect using 16 different tiles. Due to the way the tiles are patterned, only rectangles can be properly modeled.

     

    [fence.png]

    Fig 166.5 - Fence pattern.

    Think of the fence as each tile being a fence post that is connected to one or more of its neighboring tiles. It also uses 16 tiles, like the Rug, but it can make odd shapes because it ignores inner corners. The main drawback of the Fence is that when you get a bunch of center tiles together, it doesn't look very natural (see illustration).

     

    [blob.png]

    Fig 166.6 - Blob pattern.

    The Blob is the most complex pattern there is, requiring a whopping 47 tiles to fully implement. However, it is the most versatile, capable of producing any connected shape you can imagine. The inner corners are what makes up the bulk of the differences.

    As you can imagine, with 47 tiles, a ruleset which creates a blog will come with 47 rules (at least) - some of which need to be in a particular order to work properly. By standardizing the blob pattern, you can create these rules instantly, simply by supplying the indexes of the 47 tiles. And like I said above, you can then add additional override rules to add random variation.


      Conclusion

    The "Making Dungeons Pretty" articles in the Blind Mapmaker are perhaps the most linked to, most discussed articles I've written. A lot of feedback came in the form of a challenge. Did you know that I could break the tiles into quarters, and then I could use like a tenth of the tiles needed to achieve the same thing? Let me take a moment to address why I think this approach is superior to that.

    First of all, this approach is designed around a one-to-one correspondence between logical tiles and view tiles. That basically means that for each logical tile in the map, there is one view tile that is the exact same size and in the exact same place. It makes it easier to explain, easier to understand, easier to manipulate, and easier to draw. You can certainly make it more complicated, if you'd like, but you are making the rules more complicated in a misguided effort to draw fewer tiles.

    Why is drawing fewer tiles misguided? Because if you are a decent pixel artist, you are probably not going to end up with tiles that can be manipulated in quarters. You are going to want greater control over how tiles connect to their neighbors. Sometimes, you are going to want to have patterns that aren't limited to 8x8 pixels.

    For instance, the Zombie Town tileset in the Free Pixel Project features multiple instances where tiles do not work when broken into quarters. One example is the roof tiles. These tiles create a half-tile high lip by offsetting the walls by four or so pixels. Another example are the buildings, which have horizontally based patterns rather than eight direction patterns. Keeping things at the tile level, rather than the sub-tile level, makes it easier to mix up the patterns and use them more efficiently.

    That's not to say that you can't use four tiles within this system. It's actually relatively simple. Just like you can have random variation by providing a list of tile indexes, you could create a special case that represents a quad-tile and list 4 indexes into the smaller quads. It might look something like:

    defaultTileIndex: { type:quadTile, indexes: [1, 2, 3, 4] }
    In other words, breaking a tile into quads could be considered a subdomain of this solution. However, as a pixel artist, I urge programmers not to break their tiles into quads. It creates very predictable, unpleasing patterns that will end up being far more limiting than the effort you are going to save by not having to draw a few extra tiles.

     

     





    Copyright 2007-2014 Sean Howard. All rights reserved.