6
\$\begingroup\$

Making transitions between two different tiles is a fairly well understood problem. The easiest way is to look at the 0.5, 0.5 and use the resulting 4 corner points to select a transition tile, as mentioned here for example: http://playtechs.blogspot.se/2007/04/tileset-design-tip.html

The problem comes when three or four tiles meet, for example:

(s for swamp, p for plains and . for water)

pppp
..sp
..pp

If we look at the offset in the corner, the edges in our transition tile looks like:

pp
.s

In this case there are no suitable transition tiles to be found, as they typically are created to work between 2 types only. Unless we work out a three way transition tile with these types, we appear to be stuck.

I'm well aware of this often referenced article on the tile transitions in Artifact: http://archive.gamedev.net/archive/reference/articles/article934.html

But looking at the actual screenshots from that game, it doesn't seem like it will yield professional looking maps: http://www.samugames.com/artifact/screenshots.htm

Aside from using intermediary tiles (which frankly would be problematic since two out of grassland/swamp/desert would then not be able to border directly to water), are there any solutions?

EDIT

I think I've found a solution that can will create terrain transitions for 3-4 different types, using only one extra transition tile to each set of transition tiles. I've yet to make an implementation of the algorithm, but the mockups look fine. This solution will work well for pixel art.

\$\endgroup\$
9
  • \$\begingroup\$ Maybe I'm not seeing it, what about the Samugames screenshots are not professional looking? I imagine you don't mean the art style because that can be changed. What's wrong with their transitions? They appear to have multiple places merged that fit your problem description. \$\endgroup\$
    – House
    Commented Jul 19, 2012 at 0:21
  • \$\begingroup\$ Look at the (awful) hill/grassland/water transitions here: samugames.com/artifact/images/screen4.png It might be possible to salvage, who knows? In any case, there's no evidence one will be able to make it look good in practice. This is a pretty old article too, but despite that I can't recall seeing this method used in any recent game, contrary to what one would expect if it had yielded sufficiently good results. \$\endgroup\$
    – Nuoji
    Commented Jul 19, 2012 at 1:28
  • 1
    \$\begingroup\$ The professional look depends on point of view. I find it quiet appealing, but it looks kind of retro. But the "unprofessional" look you mentioned is because of the assets and maybe the transitions, and not because of the technique that was used. If you have a very talented gfx guy (or girl), the output can be very nice. \$\endgroup\$ Commented Jul 19, 2012 at 14:18
  • 1
    \$\begingroup\$ @NicolBolas note how there is absolutely no sort of transition hill->water. Here's an example from an positively ancient game (Phantasie III) that still gets it right: imgur.com/WDBqp (even though it lacks almost all other finesse when it comes to terrain->terrain transitions) \$\endgroup\$
    – Nuoji
    Commented Jul 19, 2012 at 14:26
  • 1
    \$\begingroup\$ @Nuoji I don't see any difference other than art assets. Maybe I don't have the keen eye for such details. Doesn't a hill with water behind it look exactly like that? You can't see the shore because the hill is there... \$\endgroup\$
    – House
    Commented Jul 19, 2012 at 15:33

5 Answers 5

6
\$\begingroup\$

I'm currently working on a terrain mapper, that should do exactly what you want. First of all i pregenerated all transitions between the tiles (so per 2 terrain types there would be 2 tiles for the plain terrain textures and around 12 tiles for the transitions). As the number of needed tiles would grow pretty high, when you have multiple terrains and want to support transitions between all the terrain, the tileset would grow pretty big.

Now currently I'm working on another approach, that in theory (and in my prototype) worked pretty well. I still have to implement it, so the needed tilesets are created on the fly, but this is how I do it:

I have a terrain definition, where all terrain types (for example water, grass, rocks, mountains, desert etc.) are stored. After that I establish an order. I'll explain later, why this order is needed. This order defines, how the different terrain types are related to each other. For example, water is lower than sand (beach), sand is lower than grass, grass is lower than mountains. Therefore you can also say, that water and sand are lower than mountains.

Now I have a special alpha texture, that defines the possible transitions.

Here is the tileset I used in my prototype:

enter image description here

You can see, that you have some terrain textures and an alpha map. I handle the alpha map also as tiles, but they are half sized (terrain textures are 64x64, blend textures are 32x32), this means that every terrain can have up to four blend textures (northwest, northeast, southwest, southeast).

Now basically what you do, when you have a transition is this: - Draw the ground texture (blends are always applied on the higher terrain types). - Draw the alpha map with ALPHA_MAP (this only renders the alpha channel) - Draw the second texture with ALPHA_BLEND (this uses the underlying alpha channel, we rendered out before as alpha for the tile).

So basically this is the theory... But you need to do some things, that it works properly.

What I do is following:

  • First generate the level (I use perlin noise, to assign the different terrain types). The output is simply a 2d array containing the terrain types (eg: water, dirt, sand).
  • A processor goes through this array and checks for some things and outputs a MappedTileLayer (this is my datastructure, to hold the output). The processor does following:
    • Map all terrain types to the according tile in the mapped layer.
    • Find tiles, that have neighbours with different (lower) terrain types. If there is a lower neighbour, you also have to render this neighbour terrain underneath actual terrain tile, as otherwise the terrain would just blend to black.
    • Based on the neighbours, the correct blend tiles are used. Every terrain type consists of 4 blend tiles. So depending on the existing neighbours, the blend tiles are chosen.
  • My renderer renders the tiles on the viewport:
    • Render all tiles without any transitions (also render the lowest terrain, where blends will be applied). The tiles rendered in this run can be rendered without the need to apply a blend, as they are the lowest terrain tiles.
    • Select first overlay tile (currently there are max 4 overlayes. This is the case, when the tile handled has the highest height in relation to the neighbours and every neighbour is from a different terrain type).
      • Render the four blend tiles with ALPHA_MAP
      • Render the overlying tile with ALPHA_BLEND
      • Repeat this step with the next overlay tile, until there are no tiles left

Like this, you do not have to care about multiple terrain types getting together, as they are rendered from lowest to highest terrain type and the transtitions only concern the higher type.

I know that it is a bit complicated at first, but I currently don't have more graphics, because I'm in the middle of refactoring my source. I will add some graphics representing what I do as soon as possible (also better explaining every step).

I hope this will help you.

One small addition: My mapper is built, that there is a default blend texture. But you can define specific blend textures for every terrain type. So for example if you want to make smoth transitions for all mappings besides the beach to water transition, you put the smooth transition as default and only set the custom transtition for the beach tiles. Also, my mapper allows to define multiple transition tiles for each tile type (so if you have a straight edge, you can make multiple blends, so they will alternate).

** Appended ** Because I currently have no assets to show, I created a schematic drawing in paint.net. Keep in mind, I'm a programmer and not and 2d artist, so artistically it is very badly drawn, but it should show, how I tackle the problem with multiple terrain types (>2) coming together). I left out optimizations for simplicity and applying the blending is also not mentioned, as I concentrate on the problem with the multiple terrains.

I will add an example with real textures and blends as soon as I find the time to do so, so this gfx is only temporary.

Tile Mapping Example

A explanation to the image I added:

  1. In the first grid you see the terrain configuration. W stands for water, S for sand and G for grass.
  2. The second grid shows, how the blend tiles are applied. Like I mentioned earlier, the width and height of the blend tiles is the half of the terrain tiles.
  3. Here now the first render action happens. The lowest terrain type is water, so we fill all the cells (and all adjacent cells) with the water tiles. So allthough the water tiles only fill the first column of our definition, we have also to fill the second column.
  4. Now we draw the next layer, but we only draw the tiles, that won't be blended. Water is a lower terrain then sand, so we do not need to expand to that side. But because the grass is higher, we have to fill the adjacent tiles with sand, where the grass blend will be rendered over.
  5. Now based on some patterns, we render the blending parts of the sand. (Now you see, why we need to also fill the adjacent tiles).
  6. Here we have repeated steps 4 and 5 (allthough step 4 wouldn't produce any output, as all grass tiles in this examples will contain a blend).

Now you see, in the fourth row in the second column, there is a grass tile. Left of it is a water tile and on top and bottom of it are sand tiles. When we look at the rendering only for this tile, this is what happens:

  • The water tile is rendered without a blend.
  • The sand tile is rendered with a blend.
  • The grass tile is rendered with a blend.

And we could also switch one of the sand tiles and the grass tile on the right to different terrain types, it would still produce correct results.

\$\endgroup\$
6
  • \$\begingroup\$ You should add some "final result" screenshots if you have them. \$\endgroup\$
    – DampeS8N
    Commented Jul 19, 2012 at 12:58
  • \$\begingroup\$ Yeah, I would like to do that, but currently I'm in the middle of the refactoring and I wiped all outputs I made before, so I would need to create the output by hand in paint.net or something similar. I think I should find the time this weekend, to complete my refactoring. Then I can add some screens (and maybe also a video) showing what exactly happens in my implementation. \$\endgroup\$ Commented Jul 19, 2012 at 14:16
  • 1
    \$\begingroup\$ It would be good to see the final output, otherwise it's real hard to decide if it is a viable solution or not. \$\endgroup\$
    – Nuoji
    Commented Jul 19, 2012 at 14:35
  • \$\begingroup\$ Like I said, I will add the output as soon as I'm finnished... Though I added a small schematic image, that explains how I tackle the multi terrain problem. I hope it makes things a bit clearer. But the output itself strongly depend on the graphic assets (and as I am no 2d artist, my demo won't look as professional, as you could achieve with this technique). \$\endgroup\$ Commented Jul 19, 2012 at 15:20
  • 1
    \$\begingroup\$ I finished a basic version of my mapper. There are still some quirks and the used blend set is currently not very representativ (professional), but it should work as an example. Until I update my answer, I want to share two videos showing the mapper in action (used two different blend sets): youtu.be/l8PfO2eLaNc , youtu.be/V91KDzWwO24 . These are the tilesets I used: imgur.com/a/Cx8Cs . In the videos I first first drawn some tiles, but later I try to recreate my example from above. I'm also switching the max layer rendered, so you see how the renderer handles every layer. \$\endgroup\$ Commented Jul 23, 2012 at 7:43
4
\$\begingroup\$

Since you're presumably in a situation where you don't need an extremely low-budget solution for technical reasons, and you are also presumably not interested in creating the 200+ unique different tiles that you would need to handle every possible case, you may want to consider a solution that allows you to use alpha testing or blending to handle most cases (like Artifact's solution), but override specific transitions that look poor.

The "base" for any given tile type would be a single solid tile, and then you'd have the 16 or 32 transition tiles, like Artifact. But you could also add a customized transition tile set (another 16 or 32 tiles) between a specific pair of tiles that need additional help. The transition tiles could either be done alpha tested/blended, like the generic transitions, or they could completely replace the lower-precedence tile. This still isn't perfect—say you have a custom water-grass and water-hill transition, but what happens to water that has grass on one side and water on the other?—but it does at least allow you to prioritize.

Ultimately, though, I think this is very much about visual design. There's an art involved in getting a limited range of tiles to look correct, and I suspect that shading is extremely important—a boundary with no appearance of depth will be more jarring than one that's carefully designed.

\$\endgroup\$
1
\$\begingroup\$

I also think best way to use layered marching squares with alpha channels, made a simple demo with 3 layers: water, soil, grass

Here is the whole code, originally posted here:

// images:
// https://stuepfnick.macbay.net/js/tiles/test/assets/buch-outdoor-transparent.png
// https://stuepfnick.macbay.net/js/tiles/test/assets/cloudtest.png

// https://stuepfnick.macbay.net/js/tiles/test/config.js

/*
 * All important constants in one file
 * © Created by Stuepfnick
 */

// Config object as constant with frozen properties
const Config = Object.freeze ({
    // Tiles Source image URL
    tileMapSource: 'https://i.sstatic.net/kP1lm.png',
    tileMapColumns: 6,
    // Tile Size and Spacing in Pixels
    tileWidth: 16,
    tileHeight: 16,
    tileSpacing: 0,
    // MapSize in Tiles
    mapWidth: 64,
    mapHeight: 64, 
    groundColor: '#344A61',
    /*
     *  1–––2
     *  |   |
     *  4–––8
     */
    marchingIndices: [
        [ // Dirt + 36
            9,                   // 0000 - 0
            50,                  // 0001 - 1
            49,                  // 0010 - 2
            [37, 38, 39, 40],    // 0011 - 3  (2+1)
            44,                  // 0100 - 4
            [42, 48, 54, 60],    // 0101 - 5  (4+1)
            [56, 61],            // 0110 - 6  (4+2)
            36,                  // 0111 - 7  (4+2+1)
            43,                  // 1000 - 8  (8)
            [55, 62],            // 1001 - 9  (8+1)
            [47, 53, 59, 65],    // 1010 - 10 (8+2)
            41,                  // 1011 - 11 (8+2+1)
            [67, 68, 69, 70],    // 1100 - 12 (8+4)
            66,                  // 1101 - 13 (8+4+1)
            71,                  // 1110 - 14 (8+4+2)
            [45, 46, 45, 51, 45, 52, 45, 57, 45, 58, 45, 63, 45, 64, 45, 45, 45]// 1111 - 15 (8+4+2+1 )
        ],
        [ // Grass
            9,                  // 0000 - 0
            14,                 // 0001 - 1
            13,                 // 0010 - 2
            [1, 2, 3, 4],       // 0011 - 3  (2+1)
            8,                  // 0100 - 4
            [6, 12, 18, 24],    // 0101 - 5  (4+1)
            [20, 25],           // 0110 - 6  (4+2)
            0,                  // 0111 - 7  (4+2+1)
            7,                  // 1000 - 8  (8)
            [19, 26],           // 1001 - 9  (8+1)
            [11, 17, 23, 29],   // 1010 - 10 (8+2)
            5,                  // 1011 - 11 (8+2+1)
            [31, 32, 33, 34],   // 1100 - 12 (8+4)
            30,                 // 1101 - 13 (8+4+1)
            35,                 // 1110 - 14 (8+4+2)
            [15, 10, 15, 16, 15, 21, 15, 22, 15, 27, 15, 28, 15, 15, 15, 15, 15]// 1111 - 15 (8+4+2+1 )
        ]
    ]
});


// view-source:https://stuepfnick.macbay.net/js/tiles/test/input.js

/*
 * All important main routines for Tiles!
 * © Created by Stuepfnick
 */

var isMouseDown, dataSet = false;
var data = 0;

var scrollContainer = {};//document.getElementById('scroll-container');
var drawCheckbox = {};

initInput = function() {
    //var canvas = document.getElementById('demo');
    scrollContainer = document.getElementById('scroll-container');
    drawCheckbox = document.getElementById('drawCheckbox');
    //Touch Handler
    canvas.addEventListener('touchstart', touchHandler);
    canvas.addEventListener('touchmove', touchHandler);
    canvas.addEventListener('touchend', mouseUp);
    canvas.addEventListener('touchcancel', mouseUp);

    //Mouse Handler
    canvas.addEventListener('mousedown', mouseDown);
    canvas.addEventListener('mousemove', mouseHandler);
    window.addEventListener('mouseup', mouseUp)

    scrollContainer.addEventListener('wheel', wheelHandler);

    scrollContainer.addEventListener('scroll', scrollHandler);
};

touchHandler = function(e) {
    if (drawCheckbox.checked) {
        if (e.touches) {
            e.preventDefault();
            var touchX = e.touches[0].pageX - canvas.offsetLeft + scrollContainer.scrollLeft;
            var touchY = e.touches[0].pageY - canvas.offsetTop + scrollContainer.scrollTop;
            coordinatesHandler(touchX, touchY);
       };
    }
};

mouseHandler = function(e) {
    if (isMouseDown) {
        coordinatesHandler(e.offsetX, e.offsetY);
    }
};

mouseDown = function(e) {
    isMouseDown = true;
    mouseHandler(e);
};
mouseUp = function(e) {
    isMouseDown = false;
    dataSet = false;
};

coordinatesHandler = function(x, y) {
    var tileX = Math.round(x / tiles.width);
    var tileY = Math.round(y / tiles.height);
    let numberOfLayers = Config.marchingIndices.length;
    if (!dataSet) {
        data = map.getRaw(tileX, tileY) + 1;
        if (data > numberOfLayers) {
            data = 0;
        }
        dataSet = true;
    };
    if (data != map.getRaw(tileX, tileY)) {
        map.setRaw(data, tileX, tileY);
        Game.regenerateMap(tileX - 1, tileY - 1, tileX, tileY, numberOfLayers);
    };
};

wheelHandler = function(e) {
    e.preventDefault();

    scrollContainer.scrollLeft += e.deltaX;
    scrollContainer.scrollTop += e.deltaY;
};

scrollHandler = function(e) {
    //console.log(scrollContainer.scrollLeft + ', ' + scrollContainer.scrollTop)
};


// view-source:https://stuepfnick.macbay.net/js/tiles/test/tiles.js

/*
 * All important main routines for Tiles!
 * © Created by Stuepfnick
 */

// Game Object (will move some of the values below in it eventually)
var Game = {}
// var Game = {
//     canvas: {},
//     ctx: {},
//     …
// };

// Temp: Some global objects and values, will move some of them to the Game object eventually
var canvas = {};        // will become reference to Main Canvas
var ctx = {};           // wil become referene to '2d' Context of Main Canvas
var lastTimeStamp = 0;  // need a permanent variable to store the last time stamp
var speed = 60|0;       // Speed in pixels per second?

// Temp: For testing purposes only: moving coordinateX and bool for the moving direction
var reverse = false, cX = 0;

var clouds = {};

// Generate map object and fill in some values from Config
var map = {
    cols: Config.mapWidth,
    rows: Config.mapHeight,
    rawData: [],
    getRaw: function (col, row) {
        return this.rawData[row * this.cols + col]
    },
    setRaw: function (data, col, row) {
        // Get index using col & raw
        let idx = (row * this.cols + col)|0; // force to int
        // Check if data is boolean and in bounds of rawData
        if (typeof(data) === 'number' && idx < this.rawData.length) {
            this.rawData[idx] = data;
        } else { // Log the error
            console.log('Error: map.setRaw(' + data + ', ' + col + ', ' + row + ')');
        };
    },
    layers: [],
    getTile: function (layer, col, row) {
        return this.layers[layer][row * this.cols + col]
    },
    setTile: function (layer, tile, col, row) {
        // Get index using col & raw
        let idx = (row * this.cols + col)|0; // force to int
        // Check if data is number and in bounds of rawData
        if (typeof(tile) === 'number' && idx < this.layers[layer].length) {
            this.layers[layer][idx] = tile|0; //force to int
        } else { // Log the error
            console.log('Error: map.setTile(' + tile + ', ' + col + ', ' + row + ')');
        };
    }
};

// Generate tiles object and fill in some values from Config
var tiles = {
    atlas: {},
    width: Config.tileWidth,
    height: Config.tileHeight,
    spacing: Config.tileSpacing,
    cols: Config.tileMapColumns,
    ground: Config.groundIndex,
    marching: Config.marchingIndices
};

// Here everything starts
window.onload = function() {
    console.log('window.onload');
    clouds = new Image();
    clouds.src = 'https://i.sstatic.net/RKfb4.png'
    // Create and load the Tile Atlas
    tiles.atlas = new Image();
    tiles.atlas.src = Config.tileMapSource;
    
    // Need to wait for the image to fully load, before Game can be initialized and map can be drawn
    tiles.atlas.onload = function() {
        Game.init();
    };
};

// After everything is loaded, initalize the Game
Game.init = function() {
    console.log('Game.init');
    let numberOfLayers = Config.marchingIndices.length;
    console.log('layers: ' + numberOfLayers);
    // Get Canvas and Context
    canvas = document.getElementById('myCanvas');
    ctx = canvas.getContext('2d');
    // Generate Raw Data & Map
    this.generateRawData(numberOfLayers);
    this.generateMap(numberOfLayers);
    initInput();
    initStorage();
    // After Map Generation is finished, begin draw loop
    // CAUTION: needs to be bound to the Game object (.bind(Game)), as it is not called as a regular function!
    console.log('Begin draw …');
    // requestAnimationFrame lets the browser handle the timer for the draw loop, so this is recommended over a timer
    requestAnimationFrame(this.draw);
};

// Main draw loop
Game.draw = function(timeStamp) {
    // Calculate delta from timeStamp and lastTimeStamp
    var delta = (timeStamp - lastTimeStamp) / 1000.0;
    // Clear the Canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // Draw the Tile Atlas itself for testing purposes
    //ctx.drawImage(tiles.atlas, cX, 30)
    // Draw the Offscreen Canvas, which contains the map
    ctx.drawImage(this.offCanvas, 0, 0);//-cX, 0);
    // draw clouds
    drawClouds = function(targetX, targetY) {
        ctx.drawImage(
            clouds,             // source image
            0,                  // source X
            0,                  // source Y
            1024,               // source Width
            1024,               // source Heigth
            targetX,            // target X
            targetY,            // target Y
            2048,               // target width
            2048                // target heigth
        );
    }
    let cloudX = cX - scrollContainer.scrollLeft * 2;
    let cloudY = - scrollContainer.scrollTop * 2;
    drawClouds(cloudX, cloudY);
    drawClouds(cX < 0 ? cloudX + 2048 : cloudX - 2048, cloudY);
    // temp: A simple query to move the clouds
    cX += delta * speed;
    if (cX > 2048) {
        cX = -2048;
    }
    // Store the actual timeStamp in lastTimeStamp
    lastTimeStamp = timeStamp;
    // Request next animation frame from the Browser with calling this method again …
    requestAnimationFrame(Game.draw);
    // Bind the whole function to the Game object, so it is not needed to bind 
}.bind(Game);

// Function to generate a boolean array that contains the raw data needed for the Marching Cubes
Game.generateRawData = function(numberOfLayers = Config.marchingIndices.length) {
    // Needs to have 1 extra column and row! (so I use <= instead of <)
    for (let y = 0; y <= map.rows; y++) {
        for (let x = 0; x <= map.cols; x++) {
            // Add a random Boolean to rawData
            map.rawData.push(((Math.random()* numberOfLayers) + 0.5 | 0));
        }
    }
};

// Function to Generate Map Array and draw it (temporary for testing purposes)
Game.generateMap = function(numberOfLayers = Config.marchingIndices.length) {
    // Create an Offscreen Canvas with the Size of the Map and get the '2d' Context
    this.offCanvas = createOffscreenCanvas(map.cols * tiles.width, map.rows * tiles.height);
    var tCtx = this.offCanvas.getContext('2d');
    console.log('Game.generateMap offCanvas created: ' + this.offCanvas);
    // Temporary
    tCtx.fillStyle = Config.groundColor;
    tCtx.fillRect(0, 0, map.cols * tiles.width, map.rows * tiles.height);
    // Loop for creating a map with the needed Size and …
    for (let layer = 0; layer < numberOfLayers; layer++) {
        map.layers.push([]);
        for (let y = 0; y < map.rows; y++) {
            for (let x = 0; x < map.cols; x++) {
                // Append the tile to the layer array of the Map*/
                map.layers[layer].push(this.drawMarchingTile(tCtx, layer, x, y));
            }
        }
    }
    console.log('map generation finished!');
};

// Not simply a redraw, also sets the tiles (NOTE: should add a pure redraw too)
Game.regenerateMap = function(startX = 0, startY = 0, endX = map.cols - 1, endY = map.rows - 1, numberOfLayers = Config.marchingIndices.length) {
    // Get Context from Offscreen Canvas
    var tCtx = this.offCanvas.getContext('2d');
    // Check if parameters are there and numbers (otherwise autofill -> can be done in function signature)
    // if (typeof(numberOfLayers) !== 'number') {numberOfLayers = Config.marchingIndices.length;};
    // if (typeof(startX) !== 'number') {startX = 0;};
    // if (typeof(startY) !== 'number') {startY = 0;};
    // if (typeof(endX) !== 'number') {endX = map.cols-1;};
    // if (typeof(endY) !== 'number') {endY = map.rows-1;};
    // Cap the values and set to int, although map.setTile does a check itself
    startX = Math.max(startX|0, 0);
    startY = Math.max(startY|0, 0);
    endX = Math.min(endX|0, map.cols - 1);
    endY = Math.min(endY|0, map.rows - 1);
    console.log('regenerateMap: ' + startX + '-' + (endX) + ', ' + startY + '-' + (endY));
    // Redraw Background color
    let rwidth = (endX - startX + 1) * tiles.width;
    let rheight = (endY - startY + 1) * tiles.height;
    //tCtx.fillStyle = Config.groundColor;
    tCtx.fillRect(startX * tiles.width, startY * tiles.height, rwidth, rheight);
    // Loop through the part of the map that needs to be replaced
    for (let layer = 0; layer < numberOfLayers; layer++) {
        for (let y = startY; y <= endY; y++) {
            for (let x = startX; x <= endX; x++) {
                // set the tile at x, y
                map.setTile(layer, this.drawMarchingTile(tCtx, layer, x, y), x, y);
            }
        }
    }
};

Game.drawMarchingTile = function(tCtx, layer, x, y) {
    // Pass the values of rawData to marching Squares and get appropriate index, if same layer
    let mIndex = this.mSquares(map.getRaw(x, y) == layer, map.getRaw(x + 1 , y) == layer, map.getRaw(x, y + 1) == layer, map.getRaw(x+1, y+1) == layer);
    // Set the tile number to result of MS using tiles.marching Index
    let tile = tiles.marching[layer][mIndex];
    // if tile is an array, get a random one
    if (typeof(tile) === 'object' && Array.isArray(tile)) {
        tile = tile[(Math.random() * (tile.length - 1)) + 0.5|0];
    };
    // if tile is no number, fill in -1, so it draws nothing (transparent) at that place of the map
    if (typeof(tile) !== 'number') {
        tile = -1;
    }
    // force tile to int
    tile |= 0;
    // calculate sourceX and sourceY, dependent on tileSize and Spacing
    let sX = tiles.spacing + (tiles.width + tiles.spacing) * (tile % tiles.cols);
    let sY = tiles.spacing + (tiles.height + tiles.spacing) * Math.floor(tile/tiles.cols);
    // If the actual tile is something (>=0) draw a tile
    if (tile >= 0) {
        // Actually draw the tile
        tCtx.drawImage(
            tiles.atlas,        // source image
            sX,                 // source X
            sY,                 // source Y
            tiles.width,        // source Width
            tiles.height,       // source Heigth
            x * tiles.width,    // target X
            y * tiles.height,   // target Y
            tiles.width,        // target width
            tiles.height        // target heigth
            );
    }
    return tile;
};

Game.mSquares = function(up_left, up_right, down_left, down_right) {
    /*  We label the corners with those values:
    *
    *      1–––2
    *      |   |
    *      4–––8
    */
    let state = 0|0;
    // Determine which state we are in
    if (up_left){state |= 1;}
    if (up_right){state |= 2;}
    if (down_left){state |= 4;}
    if (down_right){state |= 8;}
    return state;
};

// Function for creating an Offscreen Canvas with a variable size
createOffscreenCanvas = function(width, height) {
    console.log('createOffscreenCanvas');
    var c = document.createElement('canvas');
    c.width = width;
    c.height = height;
    return c;
};


// view-source:https://stuepfnick.macbay.net/js/tiles/test/storage.js

/*
 * All important main routines for Tiles!
 * © Created by Stuepfnick
 */

var textarea = {};
var outButton = {};
var inButton = {};

initStorage = function() {
    textarea = document.getElementById('mapstorage');
    outButton = document.getElementById('exportButton');
    inButton = document.getElementById('importButton');

    outButton.onclick = exportMap;
    inButton.onclick = importMap;
}

const exportMap = function() {
    textarea.value = JSON.stringify(map.rawData);
}

const importMap = function() {
    map.rawData = JSON.parse(textarea.value);
    Game.regenerateMap();
}
* { 
                padding: 0; 
                margin: 0;
                text-align: center;
                color: #7df;
                background: #222;
            }
            canvas { 
                background: #000;
                border:1px solid #000;
            }
            #scroll-container {
                width: 800px;
                height: 600px;
                overflow: auto;
                margin: auto;
            }
<div id="scroll-container">
            <canvas id="myCanvas" width="1024" height="1024">
                Your browser does not support the HTML canvas tag.
            </canvas>
        </div>
        <div id="scroll-container">
            <canvas id="myCanvas" width="1024" height="1024">
                Your browser does not support the HTML canvas tag.
            </canvas>
        </div>
        <input type="checkbox" id = "drawCheckbox" value = "draw">
        <label for="drawCheckbox">draw</label>
        <p>
            <textarea id = "mapstorage" rows="4" cols="50">
            </textarea>
        </p>        
        <input id="exportButton" type="button" value="export"/>
        <input id="importButton" type="button" value="import"/>

The map is purely random at the moment, of course some kind of noise would work better. "draw" checkbox is only useful for touch devices. And you can import/export the array as JSON array - so you also can see how the data looks like.

\$\endgroup\$
4
  • 1
    \$\begingroup\$ You may want to embed an image of your demo or show some example assets/pseudocode in your answer. That way, even if the link rots in the future, the answer will still show clear guidance to future readers (and attract more upvotes, since users will be able to better see its quality without clicking an untrusted external link). \$\endgroup\$
    – DMGregory
    Commented Oct 11, 2022 at 12:21
  • 1
    \$\begingroup\$ I integrated the link into the post, but, as DMGregory points out, it would be nice if you could highlight and explain the main parts of the code. \$\endgroup\$
    – Vaillancourt
    Commented Oct 11, 2022 at 12:49
  • \$\begingroup\$ I see, I will try to find the time for it - wanted to put it on Github (public) too, but should do some refactoring before (as it was my first js project). Also I think the answer from "tom van green" explains the idea pretty well. Btw.: Wondering, if you can setup the terrain tiles properly in the "Tiled" editor to draw the layered terrain with automatic sides and corners. \$\endgroup\$
    – Stuepfnick
    Commented Oct 12, 2022 at 17:24
  • \$\begingroup\$ And another thing: When defining the material-ids in the corners, the middle of a cell is not defined, if 2 opposite corners are not zero. So you should decide, if you have the middle connected or transparent. (in my example it's random, so different every time you build the map from raw data) \$\endgroup\$
    – Stuepfnick
    Commented Oct 12, 2022 at 17:30
1
\$\begingroup\$

I researched a lot about this and found only 2 or 3 articles with outdated graphics.

What I would suggest today to render a 2d game-map is to use textures and dominance-based approach, where each terrain-type defines its dominance value: more dominant tiles always overlap the less dominant ones and "blend" into them. Then instead of pre-rendering specific terrain transitions use "blendmaps" to render everything on-the-fly. One blendmap can be used to render many transitions between various textures.

Initially, you can start with this:

  • define 3 terrain types and provide 3 textures for rendering
  • define 1 blendmap that will be used to blend terrain transitions

If you go with the dominance-based approach then you must account for a possibility that rendering a single tile may require at most 9 blits, one for the tile itself and 8 for neighboring tiles that are more dominant. If you aggregate all surrounding tiles that have equal dominance then you will probably end up with 1-2 blits per tile, at least according to my experience.

Also, when you start implementing a renderer that takes into account surrounding tiles and requires expensive calculations to resolve "how" the tile should be rendered, I suggest to implement a mechanism to invalidate tiles that need such recalculation, and to not do this during the rendering process itself.

Some textures / blendmaps benefits:

  • Textures look much nicer when the same tile repeats multiple times
  • Adding a new terrain type is a matter of providing a new texture

Here is a high-level article about my implementation of such problem in JS:

\$\endgroup\$
0
\$\begingroup\$

I know this is old but this has not been mentioned. Your assumption of the transition tile is wrong. Your transition tile gets 8 transitions instead of your 4. On top of that it can have multiple overlapping layers in order of precedence. In the article they mention they have the following precedence.

Jungle over forest -> forest over rocks -> rocks over hill -> hill over swamp -> swamp over desert -> desert over grass -> grass over water.

Of course this comes with it's limitations but not in the scope you are asking for. I'll take your example layout but cut off the eastern tiles so we end up with 9, we don't need more since there won't be overlaps with tiles not being direct neighbors.

ppp
..s
..p

The middle water tile has a double transition in it's top right corner and bottom right corner. So since swamp goes over plains we first draw all the plains transitions. Then we draw the swamp transition. And thus the 2 corners will end up with 2 transitions drawn over the original water tile. It is up to you to set precedence and the art but this should give you good results. This solution does not offer different transitions for each other type but it is possible with a simple if statement, however your needed assets grow exponentially and that may not worth your while. But I can imagine a transition from swamp into water looks very different then into plains. This is essentially what you are showing in the comments, the mountains blend in very well with the grass but they won't blend in with the water that well. The reason you don't see the grass blend with the water is because the mountain overlaps it. Personally I think it looks fine but I see a lot of room for improvement, the tiles used are not much better then your average programmers art (Sorry to offend the artist, but I'm sure he agree's if he practiced more since then :) ).

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .