Saturday, February 13, 2021

how to draw and manage a hex grid on a computer

One of my favorite board games is Hey, That's My Fish! It's a really cute game where color-coded teams of penguins slide around a hexagon-based ice field. Each piece of ice has 1 2 or 3 fish, and every time a penguin exits a piece the penguin claims the fish, but the piece melts, so the board is ever changing, and there's a lot of strategy in trying to block other penguins with melted ice and penguin bodies. 

It's a strategy game, but not TOO think-y, or too much secret information to keep track of, which is a good balance for me.

There's an excellent version for iOS (it's ideal as an iPad party game) and Android, but also a free online version at yucata.de. The latter is at a really nice and congenial site (the Germans love their board games) but the interface is a little clunky for Zoom/video conference play... it's a bit geared at remote players taking turns at their leisure, and the whole site is a bit Web 1.5 feeling and requires accounts to be set up, so I'm not sure if I feel comfortable recommending it for casual work get togethers.

(From a UX standpoint, one really nice feature at the Yucata version is it uses colors to highlight the last move each player made... great when a game is going fairly slowly or people aren't being 100% attentive)

I'm not sure if it's worth coding my own version of the game (like I did for the NSFW card game Joking Hazard ) especially since it only goes up to 4 players. (If it went up to 6 or 8 it would be perfect for work game night...) But, the idea of teaching a computer to do a hex grid was interesting! Many strategy games I grew up with used hexes, (I especially remember Steve Jackson's OGRE)... there's more symmetry in the 6 directions (vs square grids, where diagonal moves go a lot further than the cardinal directions)x

As my friend Jon pointed out, a hex grid can kind of be represented as a plain old square grid, with every other row offset, but still since each hex has 6 neighbors and each square grid has 8, it was worth sketching things out and trying to get a sense of how things flowed when you treated a hex grid in terms of x,y coordinates...

I do love Apple Notes App for this kind of thing!

Interesting - every hex has East/West and North/South neighbors, but when coerced to a square grid, like... G has neighbors to the NE and SE (namely C and M) but M, one row down, has NW and SW. Every other row, then, acts a bit differently (which is why it seemed like mod arithmetic might do the trick, as we'll see later on.

I don't have super-strong spatial intuition, so I decided to just start coding incrementally rather than planning everything out. And I decided to code things up in P5, using its convenient editor. 

My first program just drew a hex grid. To be frank I just dabbled numbers until things looked right.
I started using a convention of "i,j" for the hex location, and "x,y" for the coordinate on screen. Empirically I found out if "BASESZ" was the base width... every other row had to be offset, hence

x = (i + (j % 2) / 2) * BASESZ 

and the y was packed a little tighter, based on how I was drawing hexes:

(I didn't bother to check if these were "perfect" hexes, frankly if they're a little squashed it's fine)

For my second program, I started using a new data structure, just a flat list of all hexes... not quite object oriented per se, but each hex knew where it was located and could draw itself. I also draw a circle inside each hex... it was easier to do a simple pythagorean dist() to see if the mouse was over the thing than any fancier math. On every mouse move, I reset each hex's "on" key to false, and then for each hex if it's in the distance it gets set to true.

Hex Experiment 3 finally gets a little interesting. Penguins in "Hey, That's My Fish!" move a bit like a queen or rook in chess, as far as they want in any of the 6 directions (but can be blocked by missing hexes or other penguins) so I thought I'd show what moves were available from whatever hex the mouse was on:

Besides the previous version's flat list, I stored each hex in an [i,j] two dimensional array, (It's a little weird when rolling your own 2D array, Rows come first, then Columns... at least the way I think about it) I then made 6 functions, each one returning an array of hexes leading out in that direction - basically a walker loop start at the base hex and proceeding whatever direction. East and West were easy, but NW/NE/SW/SE needed a little finagling... to look up the neighbor in the row above it was moving leftor right, like for southwest the change in i (for horizontal position changed)

i -=((j+1)%2);

where j is the current row. By making it visual, it was pretty quick to figure out what the math should be - a crude but effective way of getting it right.

Finally Hex Experiment 4 uses a slightly better syntax, letting each directional be a call to the same function with just a "incrementI, incrementJ" pair of functions. That way if I ever do make penguins, I only have to fix the loop to get blocked by other penguins or missing hexes in one function instead of 6.

For safety, here's the final code:


const BASESZ = 40;

const COLCOUNT = 8;
const ROWCOUNT = 8;

const hexList = [];
const hexGrid = [];

const SHIFTLEFT = 50;
const SHIFTDOWN = 100;

const OFF = 0;
const ON = 1;
const OVER = 2;

function setup() {
  createCanvas(400, 400);

  for (let j = 0; j < ROWCOUNT; j++) {
    hexGrid[j] = [];
    for (let i = 0; i < COLCOUNT; i++) {
      const hex = makeHexForLoc(i, j);
      hexList.push(hex);
      hexGrid[j][i] = hex;
    }
  }
}

function draw() {
  background(220);
  push();
  translate(SHIFTLEFT, SHIFTDOWN);
  hexList.map((hex) => {
    drawHex(hex)
  });
  pop();
  noLoop();
}

function makeHexForLoc(i, j) {
  const x = (i + (j % 2) / 2) * BASESZ;
  const y = (BASESZ * 0.75) * j;
  return {
    i,
    j,
    x,
    y,
    mode: OFF
  };
}



function mouseMoved() {
  hexList.map((hex) => {
    hex.mode = OFF;
  });

  const boardX = mouseX - SHIFTLEFT;
  const boardY = mouseY - SHIFTDOWN;

  let pickedHex = null;
  hexList.map((hex) => {
    if (dist(boardX, boardY, hex.x, hex.y) < BASESZ / 2) {
      pickedHex = hex;
    }
  });
  if (pickedHex) {
    //Check Easts
    getOpenHexesInDir(pickedHex,()=>1,()=>0)
    	.map(hex => hex.mode = ON);
    //Check Wests
    getOpenHexesInDir(pickedHex,()=>-1,()=>0)
    	.map(hex => hex.mode = ON);
    //Check North Easts    
    getOpenHexesInDir(pickedHex,(i,j)=>j%2,()=>-1)
    	.map(hex => hex.mode = ON);
    //Check North Wests    
    getOpenHexesInDir(pickedHex,(i,j)=>-(j+1)%2,()=>-1)
    	.map(hex => hex.mode = ON);
    //Check South Easts
    getOpenHexesInDir(pickedHex,(i,j)=>j%2,()=>1)
    	.map(hex => hex.mode = ON);
    //Check South Wests    
    getOpenHexesInDir(pickedHex,(i,j)=>-(j+1)%2,()=>1)
    	.map(hex => hex.mode = ON);
    pickedHex.mode = OVER;
  }
  loop();
}


function getOpenHexesInDir(hex,iTransform,jTransform){
  let {
    i,
    j
  } = hex;
   const res = [];
  while (i < COLCOUNT && j < ROWCOUNT && 
  	i >= 0 && j >= 0) {
    res.push(hexGrid[j][i]);
    i += iTransform(i,j);
    j += jTransform(i,j);
  }
  return res;
}

function drawHex(hex) {
  const {
    x,
    y,
    mode
  } = hex;
  const sz = BASESZ / 2;
  push();
  translate(x, y);
  //sides
  line(-sz, -sz / 2, -sz, sz / 2);
  line(sz, -sz / 2, sz, sz / 2);

  //top
  line(-sz, -sz / 2, 0, -sz);
  line(0, -sz, sz, -sz / 2);

  //bottom
  line(-sz, sz / 2, 0, sz);
  line(0, sz, sz, sz / 2);

  fill(255);
  if (mode === OVER) fill(255, 128, 128);
  if (mode === ON) fill(128, 128, 255);
  circle(0, 0, sz * 1.5);

  pop();
}

I'm not sure if I'll ever make the online version of this game, but this was a satisfying bit of computer math to get done on a Saturday morning.

No comments:

Post a Comment