I'm creating a game with JavaScript and HTML Canvas. It's a multiplayer 2D game with tanks that try to hit each other. The tanks can move as well as rotate. How can you figure out collision detection with rotating rectangular objects? I know, I could make them square and use circular detection, but it looks very messy when a tank runs into a wall. Thanks for all who try to help :)
问题:
回答1:
Move hit point to local space
First an alternative
There are many ways you can do it. The simplest way. When you calculate the cross product between a point and a line it will be negative if the point is right of the line and positive if left. If you then do each of the four sides in turn and they are all the same sign the point must be inside.
To get the cross product of a line and a point
//x1,y1,x2,y2 is a line
// px,py is a point
// first move line and point relative to the origin
// so that the line and point is a vector
px -= x1;
py -= y1;
x2 -= x1;
y2 -= y1;
var cross = x2 * py - y2 * px;
if(cross < 0){
// point left of line
}else if(cross > 0) {
// point right of line
}else {
// point on the line
}
A Quicker way.
But that is a lot of math for each object and each bullet.
The best way is to transform the bullet into the tanks local coordinate system then its just a simple matter of testing the bounds, left, right, top, bottom.
To do that you need to invert the tanks transformation matrix. Unfortunately the easy way to do that is currently still behind browser flags/prefixes so you need to create and manipulate the transformations in javascript. (Should not be too long till ctx.getTransform() is implemented across the board and fill a very needed performance hole in the canvas 2d API)
If ctx.getTransform is available
So you have a tank at x,y and rotated r and you draw it with
ctx.translate(x,y);
ctx.rotate(r);
// render the tank
ctx.fillRect(-20,-10,40,20); // rotated about center
The transform hold everything we need to do the calcs, all we need to do is invert it and then multiply the bullet with the inverted matrix
var tankInvMatrix = ctx.getTransform().invertSelf(); // get the inverted matrix
The bullet is at bx,by so create a DOMPoint
var bullet = new DOMPoint(bx,by);
Then for each tank transform the bullet with DOMMatrix.transformPoint
var relBullet = tankInvMatrix.transformPoint(bullet); // transform the point
// returning the bullet
// relative to the tank
Now just do the test in the tanks local coord space
if(relBullet.x > -20 && relBullet.x < 20 && relBullet.x > -10 && relBullet.x < 10){
/// bullet has hit the tank
}
The Javascript way
Well until the becomes the norm you have to do it the long way. Using the same x,y,r for tank, bx,by for bullet.
// create a vector aligned to the tanks direction
var xdx = Math.cos(r);
var xdy = Math.sin(r);
// set the 2D API to the tank location and rotation
ctx.setTransform(xdx,xdy,-xdy,xdx,x,y); // create the transform for the tank
// draw the tank
ctx.fillRect(-20,-10,40,20); // rotated about center
// create inverted matrix for the tank
// Only invert the tank matrix once per frame
var d = xdx * xdx - xdy * -xdy;
var xIx = xdx / d;
var xIy = -xdy / d;
// I am skipping c,d of the matrix as it is perpendicular to a,b
// thus c = -b and d = a
var ix = (-xdy * y - xdx * x) / d;
var iy = -(xdx * y - xdy * x) / d;
// For each bullet per tank
// multiply the bullet with the inverted tank matrix
// bullet local x & y
var blx = bx * xIx - by * xIy + ix;
var bly = bx * xIy + by * xIx + iy;
// and you are done.
if(blx > -20 && blx < 20 && bly > -10 && bly < 10){
// tank and bullet are one Kaaboommmm
}
Test to make sure it works
Too many negatives, xdx,xdy etc etc for me to be able to see if I got it correct (Turned out I put the wrong sign in the determinant) so here is a quick demo to show it in action and working.
Use the mouse to move over the tank body and it will show that it is hit in red. You could extend it easily to also hit the tank moving parts. You just need the inverse transform of the turret to get the bullet in local space to do the test.
UPDATE
Add code to stop tank's visually popping in and out as the crossed canvas edge. This is done by subtracting an OFFSET
from each tank when displayed. This offset must be factored in when doing the hit test by adding OFFSET
to the test coordinates.
const TANK_LEN = 40;
const TANK_WIDTH = 20;
const GUN_SIZE = 0.8; // As fraction of tank length
// offset is to ensure tanks dont pop in and out as the cross screen edge
const OFFSET = Math.sqrt(TANK_LEN * TANK_LEN + TANK_WIDTH * TANK_WIDTH ) + TANK_LEN * 0.8;
// some tanks
var tanks = {
tanks : [], // array of tanks
drawTank(){ // draw tank function
this.r += this.dr;
this.tr += this.tdr;
if(Math.random() < 0.01){
this.dr = Math.random() * 0.02 - 0.01;
}
if(Math.random() < 0.01){
this.tdr = Math.random() * 0.02 - 0.01;
}
if(Math.random() < 0.01){
this.speed = Math.random() * 2 - 0.4;
}
var xdx = Math.cos(this.r);
var xdy = Math.sin(this.r);
// move the tank forward
this.x += xdx * this.speed;
this.y += xdy * this.speed;
this.x = ((this.x + canvas.width + OFFSET * 2) % (canvas.width + OFFSET * 2));
this.y = ((this.y + canvas.height + OFFSET * 2) % (canvas.height + OFFSET * 2)) ;
ctx.setTransform(xdx,xdy,-xdy,xdx,this.x - OFFSET, this.y - OFFSET);
ctx.lineWidth = 2;
ctx.beginPath();
if(this.hit){
ctx.fillStyle = "#F00";
ctx.strokeStyle = "#800";
this.hit = false;
}else{
ctx.fillStyle = "#0A0";
ctx.strokeStyle = "#080";
}
ctx.rect(-this.w / 2, -this.h / 2, this.w, this.h);
ctx.fill();
ctx.stroke();
ctx.translate(-this.w /4, 0)
ctx.rotate(this.tr);
ctx.fillStyle = "#6D0";
ctx.beginPath();
ctx.rect(-8, - 8, 16, 16);
ctx.rect(this.w / 4, - 2, this.w * GUN_SIZE, 4);
ctx.fill()
ctx.stroke()
// invert the tank matrix
var d = (xdx * xdx) - xdy * -xdy;
this.invMat[0] = xdx / d;
this.invMat[1] = -xdy / d;
// I am skipping c,d of the matrix as it is perpendicular to a,b
// thus c = -b and d = a
this.invMat[2] = (-xdy * this.y - xdx * this.x) / d;
this.invMat[3] = -(xdx * this.y - xdy * this.x) / d;
},
hitTest(x,y){ // test tank against x,y
x += OFFSET;
y += OFFSET;
var blx = x * this.invMat[0] - y * this.invMat[1] + this.invMat[2];
var bly = x * this.invMat[1] + y * this.invMat[0] + this.invMat[3];
if(blx > -this.w / 2 && blx < this.w / 2 && bly > -this.h / 2 && bly < this.h / 2){
this.hit = true;
}
},
eachT(callback){ // iterator
for(var i = 0; i < this.tanks.length; i ++){ callback(this.tanks[i],i); }
},
addTank(x,y,r){ // guess what this does????
this.tanks.push({
x,y,r,
dr : 0, // turn rate
tr : 0, // gun direction
tdr : 0, // gun turn rate
speed : 0, // speed
w : TANK_LEN,
h : TANK_WIDTH,
invMat : [0,0,0,0],
hit : false,
hitTest : this.hitTest,
draw : this.drawTank,
})
},
drawTanks(){ this.eachT(tank => tank.draw()); },
testHit(x,y){ // test if point x,y has hit a tank
this.eachT(tank => tank.hitTest(x,y));
}
}
// this function is called from a requestAnimationFrame call back
function display() {
if(tanks.tanks.length === 0){
// create some random tanks
for(var i = 0; i < 100; i ++){
tanks.addTank(
Math.random() * canvas.width,
Math.random() * canvas.height,
Math.random() * Math.PI * 2
);
}
}
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
ctx.globalAlpha = 1; // reset alpha
ctx.clearRect(0, 0, w, h);
// draw the mouse
ctx.fillStyle = "red";
ctx.strokeStyle = "#F80";
ctx.beginPath();
ctx.arc(mouse.x,mouse.y,3,0,Math.PI * 2);
ctx.fill();
ctx.stroke();
// draw the tanks
tanks.drawTanks();
// test for a hit (Note there should be a update, then test hit, then draw as is the tank is hit visually one frame late)
tanks.testHit(mouse.x,mouse.y);
}
//====================================================================================================
// Boilerplate code not part of answer ignore all code from here down
//====================================================================================================
var w, h, cw, ch, canvas, ctx, mouse, globalTime = 0, firstRun = true;
;(function(){
const RESIZE_DEBOUNCE_TIME = 100;
var createCanvas, resizeCanvas, setGlobals, resizeCount = 0;
createCanvas = function () {
var c,cs;
cs = (c = document.createElement("canvas")).style;
cs.position = "absolute";
cs.top = cs.left = "0px";
cs.zIndex = 1000;
document.body.appendChild(c);
return c;
}
resizeCanvas = function () {
if (canvas === undefined) {
canvas = createCanvas();
}
canvas.width = innerWidth;
canvas.height = innerHeight;
ctx = canvas.getContext("2d");
if (typeof setGlobals === "function") {
setGlobals();
}
if (typeof onResize === "function") {
if(firstRun){
onResize();
firstRun = false;
}else{
resizeCount += 1;
setTimeout(debounceResize, RESIZE_DEBOUNCE_TIME);
}
}
}
function debounceResize() {
resizeCount -= 1;
if (resizeCount <= 0) {
onResize();
}
}
setGlobals = function () {
cw = (w = canvas.width) / 2;
ch = (h = canvas.height) / 2;
}
mouse = (function () {
function preventDefault(e) {
e.preventDefault();
}
var mouse = {
x : 0,y : 0,w : 0,
alt : false,
shift : false,
ctrl : false,
buttonRaw : 0,
over : false,
bm : [1, 2, 4, 6, 5, 3],
active : false,
bounds : null,
crashRecover : null,
mouseEvents : "mousemove,mousedown,mouseup,mouseout,mouseover,mousewheel,DOMMouseScroll".split(",")
};
var m = mouse;
function mouseMove(e) {
var t = e.type;
m.bounds = m.element.getBoundingClientRect();
m.x = e.pageX - m.bounds.left + scrollX;
m.y = e.pageY - m.bounds.top + scrollY;
m.alt = e.altKey;
m.shift = e.shiftKey;
m.ctrl = e.ctrlKey;
if (t === "mousedown") {
m.buttonRaw |= m.bm[e.which - 1];
} else if (t === "mouseup") {
m.buttonRaw &= m.bm[e.which + 2];
} else if (t === "mouseout") {
m.buttonRaw = 0;
m.over = false;
} else if (t === "mouseover") {
m.over = true;
} else if (t === "mousewheel") {
m.w = e.wheelDelta;
} else if (t === "DOMMouseScroll") {
m.w = -e.detail;
}
if (m.callbacks) {
m.callbacks.forEach(c => c(e));
}
if ((m.buttonRaw & 2) && m.crashRecover !== null) {
if (typeof m.crashRecover === "function") {
setTimeout(m.crashRecover, 0);
}
}
e.preventDefault();
}
m.addCallback = function (callback) {
if (typeof callback === "function") {
if (m.callbacks === undefined) {
m.callbacks = [callback];
} else {
m.callbacks.push(callback);
}
}
}
m.start = function (element) {
if (m.element !== undefined) {
m.removeMouse();
}
m.element = element === undefined ? document : element;
m.mouseEvents.forEach(n => {
m.element.addEventListener(n, mouseMove);
});
m.element.addEventListener("contextmenu", preventDefault, false);
m.active = true;
}
m.remove = function () {
if (m.element !== undefined) {
m.mouseEvents.forEach(n => {
m.element.removeEventListener(n, mouseMove);
});
m.element.removeEventListener("contextmenu", preventDefault);
m.element = m.callbacks = undefined;
m.active = false;
}
}
return mouse;
})();
// Clean up. Used where the IDE is on the same page.
var done = function () {
window.removeEventListener("resize", resizeCanvas)
if(mouse !== undefined){
mouse.remove();
}
document.body.removeChild(canvas);
canvas = ctx = mouse = undefined;
}
function update(timer) { // Main update loop
if(ctx === undefined){
return;
}
globalTime = timer;
display(); // call demo code
//if (!(mouse.buttonRaw & 2)) {
requestAnimationFrame(update);
//} else {
// done();
//}
}
setTimeout(function(){
resizeCanvas();
mouse.start(canvas, true);
mouse.crashRecover = done;
window.addEventListener("resize", resizeCanvas);
requestAnimationFrame(update);
},0);
})();