JavaScript与正六边形的点碰撞检测

21

我正在创建一个基于HTML5画布的六边形网格系统,当画布被点击时,我需要能够检测到网格中的哪个六边形被点击了。

数小时的搜索和尝试我的方法都没有结果,从其他语言移植的实现只是让我感到困惑,我的大脑已经变得迟钝。

网格由如下图所示的扁平顶部的正六边形组成:

实际上,对于给定点和在此图像中指定大小的变量作为网格中每个六边形的大小(R、W、S、H):

我需要能够确定是否给定了一个点在六边形内部。

一个示例函数调用是 pointInHexagon(hexX, hexY, R, W, S, H, pointX, pointY),其中hexX和hexY是六边形瓷砖的边界框左上角的坐标(就像上面图像中的左上角)。

有没有人有任何想法如何做到这一点?目前速度并不是很重要。


1
一种非数学解决方案:http://stackoverflow.com/questions/40509954/is-there-a-native-way-to-address-a-non-rectangle-object-on-canvas-in-js/40521863#40521863 - Kaiido
2
以六边形中心为枢轴点,通过角度函数来计算到边界的距离。从90度(垂直)开始,每60度最小值为-> R*Math.cos(Math.PI/6);,每60+30度最大值为-> R。因此可能是类似于d <= Math.sin(angle * Math.PI * 6) * (R-R*Math.cos(Math.PI/6)) + R*Math.cos(Math.PI/6)这样的东西。虽然我现在不能确定,在这个晚上的时间里。 - Redu
@Kaiido 我想那应该可以。我会记在心里,以备将来遇到多边形碰撞问题时使用,但我正在寻找一个数学解决方案xD。 - Oliver
@Redu 我会看一下。不过,这些变量代表什么? - Oliver
我不能确定公式,但它一定是那个的变体。你必须计算出所点击点到中心的角度和距离d,并且d必须小于根据角度计算出的数字。我明天会检查。 - Redu
显示剩余3条评论
5个回答

8

简单快速的对角矩形切片。

看到其他答案后,我发现它们都有点过于复杂了。以下方法比接受的答案快上一个数量级,不需要任何复杂的数据结构、迭代器或生成死亡内存和不必要的GC命中。它可以返回与R、H、S或W相关的十六进制单元格行和列。例子中使用了R = 50。

问题的一部分是找出一个点在矩形的哪一侧,如果矩形被对角线分割。这是一个非常简单的计算,通过将要测试的点的位置归一化来完成。

对角线切割任意矩形

例如,一个宽度为w,高度为h的矩形从左上角到右下角被分割。要查找一个点是在左边还是右边。假设矩形的左上角是rx,ry。

var x = ?;
var y = ?;
x = ((x - rx) % w) / w;
y = ((y - ry) % h) / h;
if (x > y) { 
    // point is in the upper right triangle
} else if (x < y) {
    // point is in lower left triangle
} else {
    // point is on the diagonal
}

如果您想改变对角线的方向,只需反转其中一个法线

x = 1 - x;  // invert x or y to change the direction the rectangle is split
if (x > y) { 
    // point is in the upper left triangle
} else if (x < y) {
    // point is in lower right triangle
} else {
    // point is on the diagonal
}

拆分并使用%运算符

其余的问题只是把网格拆分成(R/2)乘以(H/2)个单元,每个六边形覆盖4列和2行。每3个中的第1列将有对角线,其中每第二列具有翻转的对角线。第4、5和6个列中的每一列都将向下移动一行。通过使用%运算符,您可以非常快速地确定所在的六边形单元格。使用上述对角线拆分方法使数学计算变得简单且快速。

另外一个注意点是,返回参数retPos是可选的。如果按以下方式调用函数,则需要返回此参数:

var retPos;
mainLoop(){
    retPos = getHex(mouse.x, mouse.y, retPos);
}

代码不会产生GC压力,进一步提高了速度。

像素到十六进制坐标

从问题图返回十六进制单元格xy位置。请注意,此函数仅适用于范围0 <= x0 <= y,如果需要负坐标,则从输入中减去最小负像素x、y坐标。

// the values as set out in the question image
var r = 50; 
var w = r * 2;
var h = Math.sqrt(3) * r;
// returns the hex grid x,y position in the object retPos.
// retPos is created if not supplied;
// argument x,y is pixel coordinate (for mouse or what ever you are looking to find)
function getHex (x, y, retPos){
    if(retPos === undefined){
        retPos = {};
    }
    var xa, ya, xpos, xx, yy, r2, h2;
    r2 = r / 2;
    h2 = h / 2;
    xx = Math.floor(x / r2);
    yy = Math.floor(y / h2);
    xpos = Math.floor(xx / 3);
    xx %= 6;
    if (xx % 3 === 0) {      // column with diagonals
        xa = (x % r2) / r2;  // to find the diagonals
        ya = (y % h2) / h2;
        if (yy % 2===0) {
            ya = 1 - ya;
        }
        if (xx === 3) {
            xa = 1 - xa;
        }
        if (xa > ya) {
            retPos.x = xpos + (xx === 3 ? -1 : 0);
            retPos.y = Math.floor(yy / 2);
            return retPos;
        }
        retPos.x = xpos + (xx === 0 ? -1 : 0);
        retPos.y = Math.floor((yy + 1) / 2);
        return retPos;
    }
    if (xx < 3) {
        retPos.x = xpos + (xx === 3 ? -1 : 0);
        retPos.y = Math.floor(yy / 2);
        return retPos;
    }
    retPos.x = xpos + (xx === 0 ? -1 : 0);
    retPos.y = Math.floor((yy + 1) / 2);
    return retPos;
}

十六进制转像素

还有一个辅助函数,根据单元格坐标绘制单元格。

// Helper function draws a cell at hex coordinates cellx,celly
// fStyle is fill style
// sStyle is strock style;
// fStyle and sStyle are optional. Fill or stroke will only be made if style given
function drawCell1(cellPos, fStyle, sStyle){    
    var cell = [1,0, 3,0, 4,1, 3,2, 1,2, 0,1];
    var r2 = r / 2;
    var h2 = h / 2;
    function drawCell(x, y){
        var i = 0;
        ctx.beginPath();
        ctx.moveTo((x + cell[i++]) * r2, (y + cell[i++]) * h2)
        while (i < cell.length) {
            ctx.lineTo((x + cell[i++]) * r2, (y + cell[i++]) * h2)
        }
        ctx.closePath();
    }
    ctx.lineWidth = 2;
    var cx = Math.floor(cellPos.x * 3);
    var cy = Math.floor(cellPos.y * 2);
    if(cellPos.x  % 2 === 1){
        cy -= 1;
    }
    drawCell(cx, cy);
    if (fStyle !== undefined && fStyle !== null){  // fill hex is fStyle given
        ctx.fillStyle = fStyle
        ctx.fill();
    }
    if (sStyle !== undefined ){  // stroke hex is fStyle given
        ctx.strokeStyle = sStyle
        ctx.stroke();
    }
}

你将其简化为二进制左右测试是最直接的解决方案(== 最快的解决方案) - 做得好!你可以考虑在文档中发布这个十六进制命中测试。 :-) - markE

5

我认为你需要这样的东西~

编辑 我进行了一些数学计算,这不是一个完美的版本,但可能会对你有所帮助...

啊,你只需要一个R参数,因为根据它可以计算出HWS。这是我从你的描述中理解到的。

// setup canvas for demo
var canvas = document.getElementById('canvas');
canvas.width = 300;
canvas.height = 275;
var context = canvas.getContext('2d');
var hexPath;
var hex = {
  x: 50,
  y: 50,
  R: 100
}

// Place holders for mouse x,y position
var mouseX = 0;
var mouseY = 0;

// Test for collision between an object and a point
function pointInHexagon(target, pointX, pointY) {
  var side = Math.sqrt(target.R*target.R*3/4);
  
  var startX = target.x
  var baseX = startX + target.R / 2;
  var endX = target.x + 2 * target.R;
  var startY = target.y;
  var baseY = startY + side; 
  var endY = startY + 2 * side;
  var square = {
    x: startX,
    y: startY,
    side: 2*side
  }

  hexPath = new Path2D();
  hexPath.lineTo(baseX, startY);
  hexPath.lineTo(baseX + target.R, startY);
  hexPath.lineTo(endX, baseY);
  hexPath.lineTo(baseX + target.R, endY);
  hexPath.lineTo(baseX, endY);
  hexPath.lineTo(startX, baseY);

  if (pointX >= square.x && pointX <= (square.x + square.side) && pointY >= square.y && pointY <= (square.y + square.side)) {
    var auxX = (pointX < target.R / 2) ? pointX : (pointX > target.R * 3 / 2) ? pointX - target.R * 3 / 2 : target.R / 2;
    var auxY = (pointY <= square.side / 2) ? pointY : pointY - square.side / 2;
    var dPointX = auxX * auxX;
    var dPointY = auxY * auxY;
    var hypo = Math.sqrt(dPointX + dPointY);
    var cos = pointX / hypo;

    if (pointX < (target.x + target.R / 2)) {
      if (pointY <= (target.y + square.side / 2)) {
        if (pointX < (target.x + (target.R / 2 * cos))) return false;
      }
      if (pointY > (target.y + square.side / 2)) {
        if (pointX < (target.x + (target.R / 2 * cos))) return false;
      }
    }

    if (pointX > (target.x + target.R * 3 / 2)) {
      if (pointY <= (target.y + square.side / 2)) {
        if (pointX < (target.x + square.side - (target.R / 2 * cos))) return false;
      }
      if (pointY > (target.y + square.side / 2)) {
        if (pointX < (target.x + square.side - (target.R / 2 * cos))) return false;
      }
    }
    return true;
  }
  return false;
}

// Loop
setInterval(onTimerTick, 33);

// Render Loop
function onTimerTick() {
  // Clear the canvas
  canvas.width = canvas.width;

  // see if a collision happened
  var collision = pointInHexagon(hex, mouseX, mouseY);

  // render out text
  context.fillStyle = "Blue";
  context.font = "18px sans-serif";
  context.fillText("Collision: " + collision + " | Mouse (" + mouseX + ", " + mouseY + ")", 10, 20);

  // render out square    
  context.fillStyle = collision ? "red" : "green";
  context.fill(hexPath);
}

// Update mouse position
canvas.onmousemove = function(e) {
  mouseX = e.offsetX;
  mouseY = e.offsetY;
}
#canvas {
  border: 1px solid black;
}
<canvas id="canvas"></canvas>

只需将您的pointInHexagon(hexX,hexY,R,W,S,H,pointX,pointY)替换为var hover = ctx.isPointInPath(hexPath,x,y)

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

var hexPath = new Path2D();
hexPath.lineTo(25, 0);
hexPath.lineTo(75, 0);
hexPath.lineTo(100, 43);
hexPath.lineTo(75, 86);
hexPath.lineTo(25, 86);
hexPath.lineTo(0, 43);


function draw(hover) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = hover ? 'blue' : 'red';
  ctx.fill(hexPath);
}

canvas.onmousemove = function(e) {
  var x = e.clientX - canvas.offsetLeft, y = e.clientY - canvas.offsetTop;
  var hover = ctx.isPointInPath(hexPath, x, y)
  draw(hover)
};
draw();
<canvas id="canvas"></canvas>


这是一个很好的答案,但不幸的是它没有正确地碰撞...看起来它正在进行边界框碰撞检测,因为六边形的左侧和右侧没有被注册为碰撞。 - Oliver

3
我为您制作了一个解决方案,演示了点在三角形中的方法来解决这个问题。 http://codepen.io/spinvector/pen/gLROEp 以下是数学内容:
isPointInside(point)
{
    // Point in triangle algorithm from http://totologic.blogspot.com.au/2014/01/accurate-point-in-triangle-test.html
    function pointInTriangle(x1, y1, x2, y2, x3, y3, x, y)
    {
        var denominator = ((y2 - y3)*(x1 - x3) + (x3 - x2)*(y1 - y3));
        var a = ((y2 - y3)*(x - x3) + (x3 - x2)*(y - y3)) / denominator;
        var b = ((y3 - y1)*(x - x3) + (x1 - x3)*(y - y3)) / denominator;
        var c = 1 - a - b;

        return 0 <= a && a <= 1 && 0 <= b && b <= 1 && 0 <= c && c <= 1;
    }

    // A Hex is composite of 6 trianges, lets do a point in triangle test for each one.
    // Step through our triangles
    for (var i = 0; i < 6; i++) {
        // check for point inside, if so, return true for this function;
        if(pointInTriangle( this.origin.x, this.origin.y,
                            this.points[i].x, this.points[i].y,
                            this.points[(i+1)%6].x, this.points[(i+1)%6].y,
                            point.x, point.y))
            return true;
    }
    // Point must be outside.
    return false;
}

1
鉴于六边形适合矩形内,它还创建了4个三角形,位于矩形的角落并且在六边形外部。这段代码能否测试点是否位于这些外部三角形内?不同之处在于这四个三角形不是等边三角形,而六边形内部的六个三角形是等边的。 - Ryan Jenkin
如果您希望可点击区域是图表中给出的矩形,则这是一个更简单的点在矩形检查的情况。 - Andy Mac
1
是的,我在考虑你算法的反向操作,也就是如果点击在矩形内部且在四个外部三角形之一内,则点击不在六边形内;但是你检查六个三角形的解决方案可能会更清晰易懂。 - Ryan Jenkin
1
@AndyMac 我首先对每个六边形进行边界框测试,然后我想对任何边界框测试返回 true 的六边形进行更准确的测试。我猜如果我的六边形地图变得足够大,我只能将边界框检查应用于相对于屏幕上显示的部分的鼠标几个六边形宽度内的六边形。 - Oliver
1
@AndyMac 看起来你的算法是正确的选择。我之前看到过一个AS3的东西,也有类似的想法,但是代码完全无法阅读和解释。谢谢。 - Oliver
显示剩余3条评论

3

以下是与您的问题完全数学和功能表示。您会注意到,除了根据鼠标位置更改文本颜色的三元运算符之外,此代码中没有ifthen。实际上,整个工作只是一行简单的纯数学;

(r+m)/2 + Math.cos(a*s)*(r-m)/2;

这段代码可用于从三角形到圆形的所有多边形,因此如果您感兴趣,请继续阅读。这很简单。

为了展示功能,我不得不开发一个模拟问题的模型。我使用一个简单的实用函数在画布上绘制多边形,以便整体解决方案适用于任何多边形。以下代码片段将使用画布上下文 c ,半径 r ,边数 s ,以及画布中的本地中心坐标 cx 和 cy 作为参数,并在给定的画布上下文的正确位置绘制多边形。

function drawPolgon(c, r, s, cx, cy){ //context, radius, sides, center x, center y
  c.beginPath();
  c.moveTo(cx + r,cy);
  for(var p = 1; p < s; p++) c.lineTo(cx + r*Math.cos(p*2*Math.PI/s), cy + r*Math.sin(p*2*Math.PI/s));
  c.closePath();
  c.stroke();
}

我们还有一些其他的实用函数,可以轻松理解它们正在做什么。然而,最重要的部分是检查鼠标是否悬停在我们的多边形上。这是由实用函数isMouseIn完成的。它基本上是计算鼠标位置到多边形中心的距离和角度,然后将其与多边形的边界进行比较。多边形的边界可以通过简单的三角函数表达,就像我们在drawPolygon函数中计算顶点一样。
我们可以将多边形看作一个半径以侧数为频率振荡的圆形。振荡的峰值在给定半径值r处(恰好在角度2π/s处,其中s是边数),最小值m为r*Math.cos(Math.PI/s)(每个都显示在角度2π/s + 2π/2s = 3π/s处)。我相信表达多边形的理想方式可以通过傅里叶变换完成,但我们不需要这里。我们所需要的只是一个常数半径分量,即最小值和最大值的平均值(r+m)/2和具有边数s、幅度值(maximum - minimum)/2的振荡分量Math.cos(a*s)*(r-m)/2 。当然,根据傅里叶的陈述,我们可能会继续使用较小的振荡分量,但是对于六边形,您不需要进一步的迭代,而对于三角形,您可能需要。因此,这是我们在数学上表示多边形的方式。
(r+m)/2 + Math.cos(a*s)*(r-m)/2;

现在我们需要计算鼠标位置相对于多边形中心的角度和距离,并将其与上述表示我们多边形的数学表达式进行比较。因此,我们的魔法函数整体上按照以下方式进行编排:
function isMouseIn(r,s,cx,cy,mx,my){
  var m = r*Math.cos(Math.PI/s),   // the min dist from an edge to the center
      d = Math.hypot(mx-cx,my-cy), // the mouse's distance to the center of the polygon
      a = Math.atan2(cy-my,mx-cx); // angle of the mouse pointer
  return d <= (r+m)/2 + Math.cos(a*s)*(r-m)/2;
}

所以,以下代码演示了您可能解决问题的方法。

// Generic function to draw a polygon on the canvas

function drawPolgon(c, r, s, cx, cy){ //context, radius, sides, center x, center y
  c.beginPath();
  c.moveTo(cx + r,cy);
  for(var p = 1; p < s; p++) c.lineTo(cx + r*Math.cos(p*2*Math.PI/s), cy + r*Math.sin(p*2*Math.PI/s));
  c.closePath();
  c.stroke();
}

// To write the mouse position in canvas local coordinates

function writeText(c,x,y,msg,col){
  c.clearRect(0, 0, 300, 30);
  c.font = "10pt Monospace";
  c.fillStyle = col;
  c.fillText(msg, x, y);
}

// Getting the mouse position and coverting into canvas local coordinates

function getMousePos(c, e) {
  var rect = c.getBoundingClientRect();
  return { x: e.clientX - rect.left,
           y: e.clientY - rect.top
         };
}

// To check if mouse is inside the polygone

function isMouseIn(r,s,cx,cy,mx,my){
  var m = r*Math.cos(Math.PI/s),
      d = Math.hypot(mx-cx,my-cy),
      a = Math.atan2(cy-my,mx-cx);
  return d <= (r+m)/2 + Math.cos(a*s)*(r-m)/2;
}

// the event listener callback

function mouseMoveCB(e){
  var mp = getMousePos(cnv, e),
     msg = 'Mouse at: ' + mp.x + ',' + mp.y,
     col = "black",
  inside = isMouseIn(radius,sides,center[0],center[1],mp.x,mp.y);
  writeText(ctx, 10, 25, msg, inside ? "turquoise" : "red");
}

// body of the JS code

var cnv = document.getElementById("myCanvas"),
    ctx = cnv.getContext("2d"),
  sides = 6,
 radius = 100,
 center = [150,150];
cnv.addEventListener('mousemove', mouseMoveCB, false);
drawPolgon(ctx, radius, sides, center[0], center[1]);
#myCanvas { background: #eee;
                 width: 300px;
                height: 300px;
                border: 1px #ccc solid
          }
<canvas id="myCanvas" width="300" height="300"></canvas>


2
不错!谢谢你!可惜这个消息来得有点晚 xD - Oliver
@Tobsta 我相信我解决问题的方法有点不寻常,但同时我认为它非常高效。实际上,我已经开始思考如何将其应用于任何不规则形状的多边形,例如国家地图或类似物。然后当然,由于无法使用单个正弦分量完成,我想我必须坐下来学习一些FFT。 - Redu
感谢您的时间和努力,Redu。我在数学方面并不是天才,我很难理解您的解释。看着这个公式 (r+m)/2 + Math.cos(a*s)*(r-m)/2; 我想知道:我从哪里得到 a - four-eyes
@Stophface 谢谢。a 是鼠标指针和多边形中心之间连线与 x 轴之间的夹角(弧度制)。Math.atan2 函数非常适合进行这种计算。 - Redu

2
redblog网站上有一篇完整的解释,包括数学和实际例子。
主要思想是六边形的水平间距为六边形大小的$3/4$,垂直间距简单地为$H$,但需要将列考虑进去以考虑垂直偏移。红色的情况是通过在1/4 W切片处比较$x$和$y$来确定的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接