Search code examples
javaprocessing

How should I calculate every possible endpoint if my player can end on x movements in a turn-based game?


I'm currently recreating a Civilization game in Processing. I'm planning to implement the feature in a which a given unit can see every possible move it can make with a given number of hexes it is allowed to move. All possible endpoints are marked with red circles. However, units cannot move through mountains or bodies of water. I'm trying to approach this by finding out every possible combination of moves I can make without the unit going into a mountain or body of water but I can't figure out how I can determine every combination.

There are 6 directions that any unit can go in, north-east, north, north-west, south-east, south, south-west. The max number of movements I'm assigning to any unit would probably go up to 6. Any higher and I'm afraid processing may become to slow every time I move a unit.

I'm trying to recreate this:

enter image description here

What I'm hoping the result will look like with two possible movements (without the black arrows):

enter image description here

Raw version of that image:

enter image description here

Here is the code I use to draw the hex grid. Immediately after drawing each individual hex, its center's x coords and y coords are stored in xHexes and yHexes respectively. Also, immediately after generating the type of tile (e.g. grass, beach), the type of tile is also stored in an array named hexTypes. Therefore, I can get the x and y coords and type of hex of any hex I want on the map just by referencing its index.

Code used to draw a single hexagon:

beginShape();
for (float a = PI/6; a < TWO_PI; a += TWO_PI/6) {
  float vx = x + cos(a) * gs*2;
  float vy = y + sin(a) * gs*2;
  vertex(vx, vy);
}

x is the x coord for centre of hexagon y is the y coord for centre of hexagon gs = radius of hexagon

Code used to tesselate hex over the window creating a hex grid:

void redrawMap() {
  float xChange = 1.7;
  float yChange = 6;
  for (int y = 0; y < ySize/hexSize; y++) {
    for (int x = 0; x < xSize/hexSize; x++) {
      if (x % 2 == 1) {
        // if any part of this hexagon being formed will be visible on the window and not off the window.
        if (x*hexSize*xChange <= width+2*hexSize && int(y*hexSize*yChange) <= height+3*hexSize) {
          drawHex(x*hexSize*xChange, y*hexSize*yChange, hexSize);
        }
// only record and allow player to react with it if the entire tile is visible on the window
        if (x*hexSize*xChange < width && int(y*hexSize*yChange) < height) {
          xHexes.add(int(x*hexSize*xChange));
          yHexes.add(int(y*hexSize*yChange));
        }
      } else {
        if (x*hexSize*xChange <= width+2*hexSize && int(y*hexSize*yChange) <= height+3*hexSize) {
          drawHex(x*hexSize*xChange, y*hexSize*yChange+(hexSize*3), hexSize);
        }
        if (x*hexSize*xChange < width && int(y*hexSize*yChange+(hexSize*3)) < height) {
          xHexes.add(int(x*hexSize*xChange));
          yHexes.add(int(y*hexSize*yChange+(hexSize*3)));
        }
      }
    }
  }
}

hexSize is a user-specified size for each hexagon, determining the number of hexagons that will be on the screen.


Solution

  • This answer will help you get to this (green is plains, red is hills and blue is water, also please don't flame my terrible grid):

    Travel possibilities

    Note that there is no pathfinding in this solution, only some very simple "can I get there" math. I'll include the full code of the sketch at the end so you can reproduce what I did and test it yourself. One last thing: this answer doesn't use any advanced design pattern, but it assume that you're confortable with the basics and Object Oriented Programming. If I did something which you're not sure you understand, you can (and should) ask about it.

    Also: this is a proof of concept, not a "copy and paste me" solution. I don't have your code, so it cannot be that second thing anyway, but as your question can be solved in a bazillion manners, this is only one which I deliberately made as simple and visual as possible so you can get the idea and run with it.


    First, I strongly suggest that you make your tiles into objects. First because they need to carry a lot of information (what's on each tile, how hard they are to cross, maybe things like resources or yield... I don't know, but there will be a lot of stuff).


    The Basics

    I organized my global variables like this:

    // Debug
    int unitTravelPoints = 30; // this is the number if "travel points" currently being tested, you can change it
    
    // Golbals
    float _tileSize = 60;
    int _gridWidth = 10;
    int _gridHeight = 20;
    
    ArrayList<Tile> _tiles = new ArrayList<Tile>(); // all the tiles
    ArrayList<Tile> _canTravel = new ArrayList<Tile>(); // tiles you can currently travel to
    

    The basics being that I like to be able to change my grid size on the fly, but that's just a detail. What's next is to choose a coordinate system for the grid. I choose the simplest one as I didn't want to bust my brain on something complicated, but you may want to adapt this to another coordinate system. I choose the offset coordinate type of grid: my "every second row" is half a tile offset. So, instead of having this:

    No offset

    I have this:

    With offset

    The rest is just adjusting the spatial coordinates of the tiles so it doesn't look too bad, but their coordinates stays the same:

    With offset but the tiles closer

    Notice how I consider that the spatial coordinates and the grid coordinates are two different things. I'll mostly use the spatial coordinates for the proximity checks, but that's because I'm lazy, because you could make a nice algorithm which do the same thing without the spatial coordinates and it would probably be less costly.

    What about the travel points? Here's how I decided to work: your unit has a finite amount of "travel points". Here there's no unit, but instead a global variable unitTravelPoints which will do the same thing. I decided to work with this scale: one normal tile is worth 10 travel points. So:

    1. Plains: 10 points
    2. Hills: 15 points
    3. Water: 1000 points (this is impassable terrain but without going into the details)

    I'm not going to go into the details of drawing a grid, but that's mostly because your algorithm looks way better than mine on this front. On the other hand, I'll spend some time on explaining how I designed the Tiles.

    The Tiles

    We're entering OOP: they are Drawable. Drawable is a base class which contains some basic info which every visible thing should have: a position, and an isVisible setting which can be turned off. And a method to draw it, which I call Render() since draw() is already taken by Processing:

    class Drawable {
      PVector position;
      boolean isVisible;
    
      public Drawable() {
        position = new PVector(0, 0);
        isVisible = true;
      }
    
      public void Render() {
        // If you forget to overshadow the Render() method you'll see this error message in your console
        println("Error: A Drawable just defaulted to the catch-all Render(): '" + this.getClass() + "'.");
      }
    }
    

    The Tile will be more sophisticated. It'll have more basic informations: row, column, is it currently selected (why not), a type like plains or hills or water, a bunch of neighboring tiles, a method to draw itself and a method to know if the unit can travel through it:

    class Tile extends Drawable {
      int row, column;
      boolean selected = false;
      TileType type;
    
      ArrayList<Tile> neighbors = new ArrayList<Tile>();
    
      Tile(int row, int column, TileType type) {
        super(); // this calls the parent class' constructor
    
        this.row = row;
        this.column = column;
        this.type = type;
    
        // the hardcoded numbers are all cosmetics I included to make my grid looks less awful, nothing to see here
        position.x = (_tileSize * 1.5) * (column + 1);
        position.y = (_tileSize * 0.5) * (row + 1);
        // this part checks if this is an offset row to adjust the spatial coordinates
        if (row % 2 != 0) {
          position.x += _tileSize * 0.75;
        }
      }
    
      // this method looks recursive, but isn't. It doesn't call itself, but it calls it's twin from neighbors tiles
      void FillCanTravelArrayList(int travelPoints, boolean originalTile) {
        if (travelPoints >= type.travelCost) {
          // if the unit has enough travel points, we add the tile to the "the unit can get there" list
          if (!_canTravel.contains(this)) {
            // well, only if it's not already in the list
            _canTravel.add(this);
          }
          
          // then we check if the unit can go further
          for (Tile t : neighbors) {
            if (originalTile) {
              t.FillCanTravelArrayList(travelPoints, false);
            } else {
              t.FillCanTravelArrayList(travelPoints - type.travelCost, false);
            }
          }
        }
      }
    
      void Render() {
        if (isVisible) {
          // the type knows which colors to use, so we're letting the type draw the tile
          type.Render(this);
        }
      }
    }
    

    The Tile Types

    The TileType is a strange animal: it's a real class, but it's never used anywhere. That's because it's a common root for all tile types, which will inherit it's basics. The "City" tile may need very different variables than, say, the "Desert" tile. But both need to be able to draw themselves, and both need to be owned by the tiles.

    class TileType {
      // cosmetics
      color fill = color(255, 255, 255);
      color stroke = color(0);
      float strokeWeight = 2;
      // every tile has a "travelCost" variable, how much it cost to travel through it
      int travelCost = 10;
    
      // while I put this method here, it could have been contained in many other places
      // I just though that it made sense here
      void Render(Tile tile) {
        fill(fill);
        if (tile.selected) {
          stroke(255);
        } else {
          stroke(stroke);
        }
        strokeWeight(strokeWeight);
        DrawPolygon(tile.position.x, tile.position.y, _tileSize/2, 6);
        textAlign(CENTER, CENTER);
        fill(255);
        text(tile.column + ", " + tile.row, tile.position.x, tile.position.y);
      }
    }
    

    Each tile type can be custom, now, yet each tile is... just a tile, whatever it's content. Here are the TileType I used in this demonstration:

    // each different tile type will adjust details like it's travel cost or fill color
    class Plains extends TileType {
      Plains() {
        this.fill = color(0, 125, 0);
        this.travelCost = 10;
      }
    }
    
    class Water extends TileType {
      // here I'm adding a random variable just to show that you can custom those types with whatever you need
      int numberOfFishes = 10;
      
      Water() {
        this.fill = color(0, 0, 125);
        this.travelCost = 1000;
      }
    }
    
    class Hill extends TileType {
      Hill() {
        this.fill = color(125, 50, 50);
        this.travelCost = 15;
      }
    }
    

    Non-class methods

    I added a mouseClicked() method so we can select a hex to check how far from it the unit can travel. In your game, you would have to make it so when you select a unit all these things fall into place, but as this is just a proof of concept the unit is imaginary and it's location is wherever you click.

    void mouseClicked() {
      // clearing the array which contains tiles where the unit can travel as we're changing those
      _canTravel.clear();
    
      for (Tile t : _tiles) {
        // select the tile we're clicking on (and nothing else)
        t.selected = IsPointInRadius(t.position, new PVector(mouseX, mouseY), _tileSize/2);
        if (t.selected) {
          // if a tile is selected, check how far the imaginary unit can travel
          t.FillCanTravelArrayList(unitTravelPoints, true);
        }
      }
    }
    

    At last, I added 2 "helper methods" to make things easier:

    // checks if a point is inside a circle's radius
    boolean IsPointInRadius(PVector center, PVector point, float radius) {
      // simple math, but with a twist: I'm not using the square root because it's costly
      // we don't need to know the distance between the center and the point, so there's nothing lost here
      return pow(center.x - point.x, 2) + pow(center.y - point.y, 2) <= pow(radius, 2);
    }
    
    // draw a polygon (I'm using it to draw hexagons, but any regular shape could be drawn)
    void DrawPolygon(float x, float y, float radius, int npoints) {
      float angle = TWO_PI / npoints;
      beginShape();
      for (float a = 0; a < TWO_PI; a += angle) {
        float sx = x + cos(a) * radius;
        float sy = y + sin(a) * radius;
        vertex(sx, sy);
      }
      endShape(CLOSE);
    }
    

    How Travel is calculated

    Behind the scenes, that's how the program knows where the unit can travel: in this example, the unit has 30 travel points. Plains cost 10, hills cost 15. If the unit has enough points left, the tile is marked as "can travel there". Every time a tile is in travel distance, we also check if the unit can get further from this tile, too.

    Imaginary unit can go this far

    Now, if you're still following me, you may ask: how do the tiles know which other tile is their neighbor? That's a great question. I suppose that an algorithm checking their coordinates would be the best way to handle this, but as this operation will happen only once when we create the map I decided to take the easy route and check which tiles were the close enough spatially:

    void setup() {
      // create the grid
      for (int i=0; i<_gridWidth; i++) {
        for (int j=0; j<_gridHeight; j++) {
          int rand = (int)random(100);
          if (rand < 20) {
            _tiles.add(new Tile(j, i, new Water()));
          } else if (rand < 50) {
            _tiles.add(new Tile(j, i, new Hill()));
          } else {
            _tiles.add(new Tile(j, i, new Plains()));
          }
        }
      }
    
      // detect and save neighbor tiles for every Tile
      for (Tile currentTile : _tiles) {
        for (Tile t : _tiles) {
          if (t != currentTile) {
            if (IsPointInRadius(currentTile.position, t.position, _tileSize)) {
              currentTile.neighbors.add(t);
            }
          }
        }
      }
    }
    

    Complete code for copy-pasting

    Here's the whole thing in one place so you can easily copy and paste it into a Processing IDE and play around with it (also, it includes how I draw my awful grid):

    // Debug
    int unitTravelPoints = 30; // this is the number if "travel points" currently being tested, you can change it
    
    // Golbals
    float _tileSize = 60;
    int _gridWidth = 10;
    int _gridHeight = 20;
    
    ArrayList<Tile> _tiles = new ArrayList<Tile>();
    ArrayList<Tile> _canTravel = new ArrayList<Tile>();
    
    void settings() {
      // this is how to make a window size's dynamic
      size((int)(((_gridWidth+1) * 1.5) * _tileSize), (int)(((_gridHeight+1) * 0.5) * _tileSize));
    }
    
    void setup() {
      // create the grid
      for (int i=0; i<_gridWidth; i++) {
        for (int j=0; j<_gridHeight; j++) {
          int rand = (int)random(100);
          if (rand < 20) {
            _tiles.add(new Tile(j, i, new Water()));
          } else if (rand < 50) {
            _tiles.add(new Tile(j, i, new Hill()));
          } else {
            _tiles.add(new Tile(j, i, new Plains()));
          }
        }
      }
    
      // detect and save neighbor tiles for every Tile
      for (Tile currentTile : _tiles) {
        for (Tile t : _tiles) {
          if (t != currentTile) {
            if (IsPointInRadius(currentTile.position, t.position, _tileSize)) {
              currentTile.neighbors.add(t);
            }
          }
        }
      }
    }
    
    void draw() {
      background(0);
    
      // show the tiles
      for (Tile t : _tiles) {
        t.Render();
      }
    
      // show how far you can go
      for (Tile t : _canTravel) {
        fill(0, 0, 0, 0);
        if (t.selected) {
          stroke(255);
        } else {
          stroke(0, 255, 0);
        }
        strokeWeight(5);
        DrawPolygon(t.position.x, t.position.y, _tileSize/2, 6);
      }
    }
    
    class Drawable {
      PVector position;
      boolean isVisible;
    
      public Drawable() {
        position = new PVector(0, 0);
        isVisible = true;
      }
    
      public void Render() {
        // If you forget to overshadow the Render() method you'll see this error message in your console
        println("Error: A Drawable just defaulted to the catch-all Render(): '" + this.getClass() + "'.");
      }
    }
    
    class Tile extends Drawable {
      int row, column;
      boolean selected = false;
      TileType type;
    
      ArrayList<Tile> neighbors = new ArrayList<Tile>();
    
      Tile(int row, int column, TileType type) {
        super(); // this calls the parent class' constructor
    
        this.row = row;
        this.column = column;
        this.type = type;
    
        // the hardcoded numbers are all cosmetics I included to make my grid looks less awful, nothing to see here
        position.x = (_tileSize * 1.5) * (column + 1);
        position.y = (_tileSize * 0.5) * (row + 1);
        // this part checks if this is an offset row to adjust the spatial coordinates
        if (row % 2 != 0) {
          position.x += _tileSize * 0.75;
        }
      }
    
          // this method looks recursive, but isn't. It doesn't call itself, but it calls it's twin from neighbors tiles
          void FillCanTravelArrayList(int travelPoints, boolean originalTile) {
            if (travelPoints >= type.travelCost) {
              // if the unit has enough travel points, we add the tile to the "the unit can get there" list
              if (!_canTravel.contains(this)) {
                // well, only if it's not already in the list
                _canTravel.add(this);
              }
              
              // then we check if the unit can go further
              for (Tile t : neighbors) {
                if (originalTile) {
                  t.FillCanTravelArrayList(travelPoints, false);
                } else {
                  t.FillCanTravelArrayList(travelPoints - type.travelCost, false);
                }
              }
            }
          }
    
      void Render() {
        if (isVisible) {
          // the type knows which colors to use, so we're letting the type draw the tile
          type.Render(this);
        }
      }
    }
    
    class TileType {
      // cosmetics
      color fill = color(255, 255, 255);
      color stroke = color(0);
      float strokeWeight = 2;
      // every tile has a "travelCost" variable, how much it cost to travel through it
      int travelCost = 10;
    
      // while I put this method here, it could have been contained in many other places
      // I just though that it made sense here
      void Render(Tile tile) {
        fill(fill);
        if (tile.selected) {
          stroke(255);
        } else {
          stroke(stroke);
        }
        strokeWeight(strokeWeight);
        DrawPolygon(tile.position.x, tile.position.y, _tileSize/2, 6);
        textAlign(CENTER, CENTER);
        fill(255);
        text(tile.column + ", " + tile.row, tile.position.x, tile.position.y);
      }
    }
    
    // each different tile type will adjust details like it's travel cost or fill color
    class Plains extends TileType {
      Plains() {
        this.fill = color(0, 125, 0);
        this.travelCost = 10;
      }
    }
    
    class Water extends TileType {
      // here I'm adding a random variable just to show that you can custom those types with whatever you need
      int numberOfFishes = 10;
    
      Water() {
        this.fill = color(0, 0, 125);
        this.travelCost = 1000;
      }
    }
    
    class Hill extends TileType {
      Hill() {
        this.fill = color(125, 50, 50);
        this.travelCost = 15;
      }
    }
    
    
    void mouseClicked() {
      // clearing the array which contains tiles where the unit can travel as we're changing those
      _canTravel.clear();
    
      for (Tile t : _tiles) {
        // select the tile we're clicking on (and nothing else)
        t.selected = IsPointInRadius(t.position, new PVector(mouseX, mouseY), _tileSize/2);
        if (t.selected) {
          // if a tile is selected, check how far the imaginary unit can travel
          t.FillCanTravelArrayList(unitTravelPoints, true);
        }
      }
    }
    
    // checks if a point is inside a circle's radius
    boolean IsPointInRadius(PVector center, PVector point, float radius) {
      // simple math, but with a twist: I'm not using the square root because it's costly
      // we don't need to know the distance between the center and the point, so there's nothing lost here
      return pow(center.x - point.x, 2) + pow(center.y - point.y, 2) <= pow(radius, 2);
    }
    
    // draw a polygon (I'm using it to draw hexagons, but any regular shape could be drawn)
    void DrawPolygon(float x, float y, float radius, int npoints) {
      float angle = TWO_PI / npoints;
      beginShape();
      for (float a = 0; a < TWO_PI; a += angle) {
        float sx = x + cos(a) * radius;
        float sy = y + sin(a) * radius;
        vertex(sx, sy);
      }
      endShape(CLOSE);
    }
    

    Hope it'll help. Have fun!