一种生成随机地图的方式

1,126 阅读3分钟

前言: 前两天收到一个产品需求,需要根据不同主播的贡献值 按照比例随机生成地图。很多同学第一时间想到的可能是使用 echart 的地图,但是使用过的同学都知道 echart 里面的地图是需要各种经纬度的。笔者 google 了一下,发现很少有类似的,于是自己造了一个轮子js-map-generator.

先看看效果图

要求: 大地图随机生成,点击每一块需要使用小地图的方式填充大地图的内容,大地图相邻展示

实现方式

对于此类的需求相信大家脑袋里面蹦出来的第一个想法就是使用 canvas 进行绘制。那么我们需要解决如下几个问题:

  1. 如何随机生成地图?
  2. 如何判断点击的是哪块区域?
  3. 如何填充指定的区域块?

如何随机生成地图

在生成地图之前我们需要将地图 转换成程序里面的二维数组,以便于我们对地图的数据进行修改,删除,和查询。

const canvasWidth = ctx.canvas.width;
const canvasHeight = ctx.canvas.height;
var xLineTotals = Math.floor(canvasHeight / ItemGridSize);
var yLineTotals = Math.floor(canvasWidth / ItemGridSize);
this.numYs = xLineTotals;
this.numXs = yLineTotals;

// 构建数据二维地图
for (let i = 0; i < this.numXs; i++) {
  const tmp: {
    value: number,
    name: string,
    color?: string,
  }[] = [];
  for (let j = 0; j < this.numYs; j++) {
    tmp.push({ color: "transparent", value: -1, name: "" });
  }
  this.dataMap.push(tmp);
}

二维数组构建好之后,我们开始着手考虑如何生成一个随机地图,其步骤大致如下:

  1. 选取起始点(默认以地图中心);
  2. 判断起始点是否已被绘制
    • 已被绘制: 随机一个方向继续查询
    • 未被绘制: 标记数据地图
  3. 找起始点的随机一个方向,看随机方向是否可以使用
    • 可以使用: 标记数据地图
    • 不可使用: 找随机方向的随机方向进行判断

其代码大致如下:

// 获取一个点的随机方向
 _getContiguous(frontier: PointType) {
    return [
      [0, 1],
      [0, -1],
      [1, 0],
      [-1, 0],
    ].map((dir) => ({
      x: frontier.x + dir[0],
      y: frontier.y + dir[1],
    }));
  }

// 数据填充

fill(dataMap: DataMapType, start: PointType) {
  // 保证循环次数最多整个网格的数量的一半
    if (this._loopCount >= (this.xCount * this.yCount * 2) / 3) {
      console.log("超出查询最大的次数");
      return;
    }
    this._loopCount++;
    // 未被占用则标记
    if (
      start.x > 0 &&
      start.y > 0 &&
      dataMap[start.x][start.y] &&
      dataMap[start.x][start.y].value === -1
    ) {
      this.frontierCount++;
      this.frontiers[`${start.x}:${start.y}`] = true;
      this.changeMapItem(dataMap, start.x, start.y);
    }
    // 随机找前后左右
    let newCoors = this._getContiguous({
      x: start.x,
      y: start.y,
    });
    // 找前后最后可以使用的
    let canUseCoors = this.filterCanUse(dataMap, newCoors);

    if (canUseCoors.length === 0) {
      // 极端情况处理
      // 找斜对角
      const skewCoors = this._getSkewContiguous(start);
      const skewCanUse = this.filterCanUse(dataMap, skewCoors);
      if (skewCanUse.length === 0) {
        // 相当于一个点的前后左右,斜对面全部被占满了,则尽量从前后左右再次突围
        this.fill(dataMap, newCoors[random(3)]);
        return;
      } else {
        newCoors = skewCoors;
        canUseCoors = skewCanUse;
      }
    }
    // 标记所有可用的
    for (let j = 0; j < canUseCoors.length; j++) {
      const ele = canUseCoors[j];
      this.changeMapItem(dataMap, ele.x, ele.y);
      this.frontiers[`${ele.x}:${ele.y}`] = true;

      this.frontierCount++;
    }

   // ... 继续进行随机fill

}

如何保证可连续性?

如果每一次都从中心点进行判断肯定大量的不必要的判断; 我们可以找到已经生成的地图的边界,然后从随机边界上依次递归判断;

如何判断点击的是哪块区域?

经过上面随机地图的生成,我们已经将要渲染的部分标记到数据地图上,由此我们可以对 canvas 监听绑定事件,判断触发的位置是否存在某个区域即可。

function getEventPosition(ev: any) {
  var x, y;
  if (ev.layerX || ev.layerX === 0) {
    x = ev.layerX;
    y = ev.layerY;
  } else if (ev.offsetX || ev.offsetX === 0) {
    x = ev.offsetX;
    y = ev.offsetY;
  }
  return { x: x, y: y };
}
  getEventInWhereMap(position: PointType) {
    for (let i = 0; i < this.labelQueue.length; i++) {
      const ele = this.labelQueue[i];
      if (ele.positions[`${position.x}:${position.y}`]) {
        return { id: ele.id, index: i };
      }
    }
    return {
      index: -1,
      id: "",
    };
  }


this.canvasRef.current?.addEventListener("click", (event) => {
  // 获取事件的坐标
  const p = getEventPosition(event);
  // 转换为数据地图中的坐标
  const mapPosition = {
    x: Math.floor(p.x / ItemGridSize),
    y: Math.floor(p.y / ItemGridSize),
  };

 // 查看点击位置在那个区域
  const selectMap = this.getEventInWhereMap(mapPosition);
  if (selectMap.index !== -1) {
    const info = this.labelQueue[selectMap.index];
    this.props.callback && this.props.callback(info);
  }
});

如何填充指定的区域块?

笔者一共提供了四种方式:

  • 只展示父地图
  • 横向按比例展示子地图
  • 纵向按比例展示子地图
  • 随机占比展示子地图

父地图

最简单的方式是父节点和子节点网格数量一致,按照对应的数据地图进行填充即可直接得到父区域的内容;但是考虑到子地图展示的区域很小,无需较大画布展示,所以我们需要进行一层坐标数据转换。

if (props.center && props.positions) {
  //找到子地图的中心
  const mapCenterX = Math.floor(this.numXs / 2);
  const mapCenterY = Math.floor(this.numYs / 2);
  const { center, color } = props;
  const xC = center.x - mapCenterX;
  const yC = center.y - mapCenterY;

  // 更新前先清除一波
  this.clearMap();
  // 将父区域部分 复制到子地图的中心
  for (let i = 0; i < this.numXs; i++) {
    for (let j = 0; j < this.numYs; j++) {
      if (props.positions[`${i + xC}:${j + yC}`]) {
        this.dataMap[i][j].value = 0;
        this.dataMap[i][j].color = color;
      }
    }
  }
}

横向按比例展示子地图

for (let y = 0; y < this.yCount; y++) {
  for (let x = 0; x < this.xCount; x++) {
    if (dataMap[x][y].value === 0 && count <= this.value) {
      this.frontiers[`${x}:${y}`] = true;
      this.changeMapItem(dataMap, x, y);
      count++;
    }
  }
}

纵向按比例展示子地图

for (let x = 0; x < this.xCount; x++) {
  for (let y = 0; y < this.yCount; y++) {
    if (dataMap[x][y].value === 0 && count <= this.value) {
      this.frontiers[`${x}:${y}`] = true;
      this.changeMapItem(dataMap, x, y);
      count++;
    }
  }
}

随机占比展示子地图

此处逻辑和父地图逻辑类似,有兴趣可以查看源码

至此整个地图大致生成完毕,当然还有很多需要处理的边界情况。

封装使用

上面讲了这么多,笔者已经将其封装成一个 react 组件,有兴趣的同学可以按照如下的方式使用:

npm install js-map-generator

详情请点击js-map-generator查看如何使用。