Search code examples
javascriptalgorithmmatrixtileroguelike

Algorithm for creating a list of rectangles from a list of squares?


I'm trying to implement line-of-sight visibility / fog-of-war for my 2D top-down game, and found this article which has a simple and efficient algorithm which involves shooting rays at the edges of rectangles to calculate a list of triangles to brighten.

However, my game uses tiles, so it would be very inefficient to run this against the 150x150 (22,500) tiles surrounding the players every frame. Rather, it would be better to instead convert the tile-map to a list of rectangles and then run the line-of-sight algorithm against that. For example, if this were my tile-map (where 1 is a solid tile and 0 is a free tile):

1 1 1 1 1 1 1 
1 0 0 0 0 0 1 
1 0 0 0 0 0 1 
0 0 1 1 0 0 1  
0 0 1 1 1 0 1 
1 0 0 0 0 0 1 
1 1 1 1 1 1 1 

Then you could convert that into a list of rectangles like so:

1 1 1 1 1 1 1 
5 0 0 0 0 0 2 
5 0 0 0 0 0 2 
0 0 6 6 0 0 2  
0 0 6 6 7 0 2 
4 0 0 0 0 0 2 
3 3 3 3 3 3 3

In this result every 1 refers to the first rectangle, every 2 refers to the second rectangle, etc. This is not the only possible outcome, but one of the many possible ones. Basically, instead of there being 7 x 7 = 49 tiles to check against, the algorithm only has to check against 7 rectangles, which greatly speeds up the field-of-view calculations.

The rectangles would have properties like x, y, width, height. In this case the first rectangle would be something like x: 0, y: 0, w: 7, h: 1. The second rectangle would be x: 6, y: 1, w: 1, h: 5, etc.

Is there an algorithm to generate a list of rectangles from a 2D tile matrix?


Solution

  • There is naive way, for example, start top left in a width-first filling process, that would give you similar results to what you're looking for.

    Here's what the width-first algo might look like:

    Results in:

    1 1 1 1 1 1 1
    2 0 0 0 0 0 3
    2 0 0 0 0 0 3
    0 0 4 4 0 0 3
    0 0 4 4 5 0 3
    6 0 0 0 0 0 3
    6 7 7 7 7 7 7
    
    • create empty array tileCache for storing which tiles have been processed, prefill it with 0
    • create an array tileRects for storing rectangle info
    • loop through tiles by row and column
    • check if tile equals 1, if not, process to next tile
    • check if tileCache at coordinates does not equal to 0; if != 0 process next tile
    • if it's a novel tile, find tiles to the right that are 1 and get the width of the rectangle
    • use the width of the rectangle (and x,y) to find out how tall we can make the rectangle to get the height
    • then use x,y,width and height to create the rectangle object
    • fill the cache with the data

    Note that there is one quirk of this algorithm, that shouldn't be an issue, and can be fixed if it is an issue. It will allow overlapping of rectangles.

    ie

    let tiles = [
      [1, 1, 1, 1],
      [0, 1, 1, 0],
      [1, 1, 1, 1],
      [0, 1, 1, 0],
    ];
    

    will generate 3 rectangles

    1 1 1 1
    0 2 2 0
    3 3 3 3
    0 2 2 0
    

    let tiles = [
      [1, 1, 1, 1, 1, 1, 1],
      [1, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 1],
      [0, 0, 1, 1, 0, 0, 1],
      [0, 0, 1, 1, 1, 0, 1],
      [1, 0, 0, 0, 0, 0, 1],
      [1, 1, 1, 1, 1, 1, 1],
    ];
    
    const tileCache = tiles.map(() => Array(tiles[0].length).fill(0));
    
    const tileRects = [];
    
    function exploreRight(y, x) {
      let w = 1;
      while (tiles[y][x + w] === 1) {
        w++;
      }
      return w;
    }
    
    function exploreDown(y, x, w) {
      let pass = true;
      let h = 1;
      while (pass) {
        for (let $x = x; $x < x + w; $x++) {
          if (!tiles[y + h] || tiles[y + h][$x] !== 1) {
            pass = false;
            continue;
          }
        }
        pass && h++;
      }
      return h;
    }
    
    function fillCache(y, x, h, w, n) {
      for (let $y = y; $y < y + h; $y++) {
        for (let $x = x; $x < x + w; $x++) {
          tileCache[$y][$x] = n;
        }
      }
    }
    
    let n = 1;
    for (let y = 0; y < tiles.length; y++) {
      for (let x = 0; x < tiles[y].length; x++) {
        const tile = tiles[y][x];
        const cache = tileCache[y][x];
        if (tile === 0) {
          continue;
        }
    
        if (cache > 0) {
          continue;
        }
    
        const w = exploreRight(y, x);
        const h = exploreDown(y, x, w);
        tileRects.push({ y, x, w, h });
        fillCache(y, x, h, w, n);
    
        if (w > 1) {
          x += w - 1;
        }
        n++;
      }
    }
    
    console.log(tileCache.map((r) => r.join(" ")).join("\n"));
    
    console.log(tileRects);

    Update

    If the overlapping squares are an issue, the only change needed is that the exploreDown method should check against the cache too, not just tiles. The assumption is that fewer rectangles means fewer calculations, but in some cases the overlap might be an issue.

    function exploreDown(y, x, w) {
      let pass = true;
      let h = 1;
      while (pass) {
        for (let $x = x; $x < x + w; $x++) {
          if (!tiles[y + h] || tiles[y + h][$x] !== 1) {
            pass = false;
            continue;
          }
          /// change 👇
          if (!tileCache[y + h] || tileCache[y + h][$x] !== 1) {
            pass = false;
            continue;
          }
          // change 👆
        }
        pass && h++;
      }
      return h;
    }