如何在与正方形碰撞后保持圆的速度?

6
我正在开发一个游戏,玩家是一个圆形,方块是瓷砖。用户用键盘移动角色(圆形),不应与方块(正方形)碰撞。
此外,如果圆形碰到方块的角落,我希望圆形能够沿着方块滑动,这样如果玩家继续按下相同方向的键移动,他们将沿着方块滑动而不是被卡住。
我在这里开发了一个完整的问题重现。

let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");

class Vec2 {
  constructor(x, y) {
    this.x = x || 0;
    this.y = y || 0;
  }

  distance(v) {
    let x = v.x - this.x;
    let y = v.y - this.y;

    return Math.sqrt(x * x + y * y);
  }

  magnitude() { 
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }

  dot(v) { 
    return this.x * v.x + this.y * v.y;
  }

  normalize() {
    let magnitude = this.magnitude();
    
    return new Vec2(this.x / magnitude, this.y / magnitude);
  }
  
  multiply(val) {
    return typeof val === "number" ? new Vec2(this.x * val, this.y * val) : new Vec2(this.x * val.x, this.y * val.y);
  }

  subtract(val) {
    return typeof val === "number" ? new Vec2(this.x - val, this.y - val) : new Vec2(this.x - val.x, this.y - val.y);
  }

  add(val) {
    return typeof val === "number" ? new Vec2(this.x + val, this.y + val) : new Vec2(this.x + val.x, this.y + val.y);
  }
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function drawCircle(xCenter, yCenter, radius) {
  ctx.beginPath();
  ctx.arc(xCenter, yCenter, radius, 0, 2 * Math.PI);
  ctx.fill();
}

function drawSquare(x, y, w, h) {
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.stroke();
}

function circleRectangleCollision(cX, cY, cR, rX, rY, rW, rH) {
  let x = clamp(cX, rX, rX + rW);
  let y = clamp(cY, rY, rY + rH);

  let cPos = new Vec2(cX, cY);

  return cPos.distance(new Vec2(x, y)) < cR;
}

function getCircleRectangleDisplacement(rX, rY, rW, rH, cX, cY, cR, cVel) {
  let circle = new Vec2(cX, cY);

  let nearestX = Math.max(rX, Math.min(cX, rX + rW));
  let nearestY = Math.max(rY, Math.min(cY, rY + rH));    
  let dist = new Vec2(cX - nearestX, cY - nearestY);

  let tangentVel = dist.normalize().dot(cVel);

  // The original answer had `cVel.subtract(tangentVel * 2);` here
  // but that was giving me issues as well
  return cVel.add(tangentVel);
}

let circlePos = new Vec2(150, 80);
let squarePos = new Vec2(240, 110);

let circleR = 50;

let squareW = 100;
let squareH = 100;

let circleVel = new Vec2(5, 0);

draw = () => {
  ctx.fillStyle = "#b2c7ef";
  ctx.fillRect(0, 0, 800, 800); 

  ctx.fillStyle = "#ffffff";

  drawCircle(circlePos.x, circlePos.y, circleR);
  drawSquare(squarePos.x, squarePos.y, squareW, squareH);
}

update = () => {
  draw();

  if (circleRectangleCollision(circlePos.x, circlePos.y, circleR, squarePos.x, squarePos.y, squareW, squareH)) {
    circleVel = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR, circleVel);
  }

  circlePos = circlePos.add(circleVel);
}

setInterval(update, 30);
canvas { display: flex; margin: 0 auto; }
<canvas width="800" height="800"></canvas>

如果您运行代码段,您会发现圆形正确地围绕正方形移动,但之后它向下和向右移动。我不确定为什么会发生这种情况。它应该在此后完全向右移动而不偏离。
不幸的是,我不太擅长数学,所以我很难弄清楚为什么会出现这种情况。我了解到主要算法通过这个答案,但也参考了以下答案: 我还注意到另一个问题,如果您将circlePos的y位置从80更改为240,则它仍然沿着正方形的顶部滑动,而不是沿着正方形的底部滑动。如果可能的话,我想修复这个问题。
此外,如果圆形与瓷砖正面相撞,则理想情况下不应该有任何滑动。在这种情况下,它应该被卡在方块上。

关于您最后一段的问题:演示中圆形是“正面撞击”的(我理解为:垂直于正方形的边),那么为什么它会滑动呢?或者说,圆形滑动的条件是它撞到正方形的角落吗?如果它撞到了正方形的边呢?如果它以非垂直的方式撞到了边呢? - trincot
@trincot 抱歉,我觉得我没有恰当地解释清楚。我不确定正确的解释方式,但基本上,如果圆形直接朝着正方形而来,但只是勉强碰到了边缘,它应该会绕过它滑动。就像这个GIF中发生的一样:https://dl.dropboxusercontent.com/s/joa8cdeddygje5f/round.gif 但是,如果圆形靠近正方形,以至于大部分圆形都嵌入到正方形中,那么它应该会被卡在旁边。只有在与正方形轻微碰撞时,它才会绕过正方形滑动,如果这样说有意义的话。 - Ryan Peschel
圆的初始运动始终是纯水平还是纯垂直的?如果圆以对角线路径完全击中正方形,那么它应该也会停留吗? - trincot
圆形的初始移动肯定可以是对角线的,因为玩家(圆形)可以使用WASD键中的任意一个方向移动。老实说,我很难理解它应该在什么时候停止,而且我不确定是否有客观答案。基本上,我只是想改进当前情况,即当玩家四处走动时,总是会卡在可碰撞正方形瓷砖的边缘,这非常令人沮丧。我希望在尽可能多的情况下支持滑动。抱歉我的回答不是很技术性的。 - Ryan Peschel
我明白了,所以移动实际上不是倾斜15度,而只有8个方向,是45度的倍数。 - trincot
1
嘿,我刚醒来,很抱歉回复晚了。我正在查看你的答案。 - Ryan Peschel
1个回答

1
我建议进行以下更改:
在你的类中定义另外两个方法:
  crossProductZ(v) {
    return this.x * v.y - v.x * this.y;
  }
  
  perpendicular() {
    return new Vec2(this.y, -this.x);
  }

getCircleRectangleDisplacement 中,用以下内容替换 return 语句:

return dist.perpendicular().normalize()
           .multiply(cVel.magnitude() * Math.sign(cVel.crossProductZ(dist)));

这个想法是圆应该垂直于通过圆心和命中点(即dist)的直线移动。当然,在垂直线上有两个方向:应该选择与当前速度矢量在dist同侧的方向。这样,圆将选择正方形的正确一侧。
该移动的大小应该等于当前速度的大小(这样速度不会改变,只是方向改变)。
最后,还要对update函数进行此更改:
  let nextCirclePos = circlePos.add(circleVel);
  if (circleRectangleCollision(nextCirclePos.x, nextCirclePos.y, circleR, squarePos.x, squarePos.y, squareW, squareH)) {
    let currentVel = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR, circleVel);
    nextCirclePos = circlePos.add(currentVel);
  }
  circlePos = nextCirclePos;

这里的思路是,我们首先像往常一样进行移动(circleVel),并查看是否会发生碰撞。在这种情况下,我们不进行该移动。相反,我们从当前位置获取位移。

而且,我们永远不会更新currentVel。这将确保一旦障碍物消失,运动将继续。

在下面的代码片段中进行了这些更改。另外,我添加了一个在圆形路径上的第二个正方形,一旦圆形消失,我添加了一个第二次运行,在此期间圆形采取不同的路径:

let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");

class Vec2 {
  constructor(x, y) {
    this.x = x || 0;
    this.y = y || 0;
  }

  distance(v) {
    let x = v.x - this.x;
    let y = v.y - this.y;

    return Math.sqrt(x * x + y * y);
  }

  magnitude() { 
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }

  dot(v) { 
    return this.x * v.x + this.y * v.y;
  }

  normalize() {
    let magnitude = this.magnitude();
    
    return new Vec2(this.x / magnitude, this.y / magnitude);
  }
  
  multiply(val) {
    return typeof val === "number" ? new Vec2(this.x * val, this.y * val) : new Vec2(this.x * val.x, this.y * val.y);
  }

  subtract(val) {
    return typeof val === "number" ? new Vec2(this.x - val, this.y - val) : new Vec2(this.x - val.x, this.y - val.y);
  }

  add(val) {
    return typeof val === "number" ? new Vec2(this.x + val, this.y + val) : new Vec2(this.x + val.x, this.y + val.y);
  }
  
  crossProductZ(v) {
    return this.x * v.y - v.x * this.y;
  }
  
  perpendicular() {
    return new Vec2(this.y, -this.x);
  }
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function drawCircle(xCenter, yCenter, radius) {
  ctx.beginPath();
  ctx.arc(xCenter, yCenter, radius, 0, 2 * Math.PI);
  ctx.fill();
}

function drawSquare(x, y, w, h) {
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.stroke();
}

function circleRectangleCollision(cX, cY, cR, rX, rY, rW, rH) {
  let x = clamp(cX, rX, rX + rW);
  let y = clamp(cY, rY, rY + rH);

  let cPos = new Vec2(cX, cY);

  return cPos.distance(new Vec2(x, y)) < cR;
}

function getCircleRectangleDisplacement(rX, rY, rW, rH, cX, cY, cR, cVel) {
  let circle = new Vec2(cX, cY);

  let nearestX = clamp(cX, rX, rX + rW);
  let nearestY = clamp(cY, rY, rY + rH);
  let dist = new Vec2(cX - nearestX, cY - nearestY);

  return dist.perpendicular().normalize().multiply(cVel.magnitude() * Math.sign(cVel.crossProductZ(dist)));
}

let circlePos = new Vec2(100, 80);
let squarePosList = [new Vec2(240, 110), new Vec2(480, -50)];

let circleR = 50;

let squareW = 100;
let squareH = 100;

let circleVel = new Vec2(5, 0);

draw = () => {
  ctx.fillStyle = "#b2c7ef";
  ctx.fillRect(0, 0, 800, 800); 

  ctx.fillStyle = "#ffffff";

  drawCircle(circlePos.x, circlePos.y, circleR);
  for (let squarePos of squarePosList) {
    drawSquare(squarePos.x, squarePos.y, squareW, squareH);
  }
}

update = () => {
  draw();

  let nextCirclePos = circlePos.add(circleVel);
  for (let squarePos of squarePosList) {
    if (circleRectangleCollision(nextCirclePos.x, nextCirclePos.y, circleR, squarePos.x, squarePos.y, squareW, squareH)) {
      let currentVel = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR, circleVel);
      nextCirclePos = circlePos.add(currentVel);
      break; // we only deal with one collision (otherwise it becomes more complex)
    }
  }
  circlePos = nextCirclePos;
  if (circlePos.x > 800 + circleR) { // Out of view: Repeat the animation but with a diagonal direction
       circlePos = new Vec2(100, 400);
       circleVel = new Vec2(3.6, -3.6);
  }
}

let interval = setInterval(update, 30);
canvas { display: flex; margin: 0 auto; }
<canvas width="800" height="800"></canvas>

注意:在碰撞和位移函数中存在一些代码重复。它们都几乎计算相同的内容。这可以进行优化。

非常感谢您提供如此精彩的答案!我现在正在尝试将其实现到我的游戏中,但是我遇到了一些问题。似乎玩家有时会被卡在正方形里面。我也注意到在您提供的代码中有这种情况。例如,如果您将circleVel设置为(3, 3)circlePos设置为(35, 80),并将squarePosList设置为[new Vec2(140, 180), new Vec2(140, 280), new Vec2(140, 380)],则圆圈不仅有时会出现故障,而且有时还会被卡住,如下图所示:https://i.imgur.com/J1f2q0l.png和https://i.imgur.com/6uxbhpj.png - Ryan Peschel
哦,我在我的游戏中实际上不能这样做(将正方形组成矩形),因为玩家是在图块网格上移动。就像想象一下2D俯视《塞尔达传说》游戏,在那里,实心墙和可碰撞物体都是实心正方形,你无法与之碰撞。 - Ryan Peschel
没错,那个方法可行。唔,我在游戏中为什么还是不行呢?出现了一些问题,玩家总是穿过墙壁。碰撞似乎不太稳定。我会接受你的答案,但你有任何想法吗?我的代码看起来像这样:https://pastebin.com/f3EWHmpx - Ryan Peschel
你的代码看起来有点不同。请注意我的答案中调用 getCircleRectangleDisplacement 的是原始位置,而不是(暂时)移动后的位置。我不知道这个调用是否对应于你的 getSlidingVelocity 调用,但如果是的话,你应该做同样的事情。 - trincot
1
哦,好主意。嗯,尽管做出了那个更改,但这个错误仍然存在,但是如果我只是发布随机片段,这对你来说太难调试了。无论如何,还是谢谢!我会尝试自己解决它。你已经给了我一个很好的基础。 - Ryan Peschel
显示剩余2条评论

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