1
\$\begingroup\$

In my top down 2D shooter game the player has a shoulder mounted gun which rotates and aims automatically at enemies - that part is all working fine. Since the player can rotate as well, the gun is constantly updating it's angle to keep aim on the enemy.

But this causes a problem where the gun aims at the player's own head...! It doesn't have any practical consequences in the game, but obviously looks weird and unrealistic. So I need to create a 'dead zone' angle range where the gun cannot aim. It will have to aim as best it can at an enemy up to just before it aims at the player's head.

It seems tough to calculate since there are two angles in play. The dead zone will have to update itself as the player turns.

enter image description here

EDIT: This is what I had so far but I can't update the angle range since it then wouldn't work with the min max:

private float normaliseAngle(float angle) {
    return angle - (float) Math.floor(angle / 360f) * 360f;
}

private float clampAngle(float aimAngle) {
    float tempAngle = normaliseAngle(aimAngle);
    return (float) Math.min(Math.max(tempAngle, 45), 315);
}

This gives a dead zone between 315 and 45.

\$\endgroup\$
  • \$\begingroup\$ Umm, isn't it a simple addition? Let's say you have initial dead zone range, then transformed range would be player rotation angle + dead zone \$\endgroup\$ – Ocelot Oct 27 at 5:38
  • \$\begingroup\$ see example desmos.com/calculator/pin6udumxo \$\endgroup\$ – Ocelot Oct 27 at 5:41
  • \$\begingroup\$ @Ocelot Yeah I guess you're probably right, that never occurred to me. \$\endgroup\$ – Hasen Oct 27 at 11:10
  • \$\begingroup\$ @Ocelot Actually it seems it's really not that simple. I was implementing the dead zone with min and max, like for example Math.max(Math.min(aimAngle, 360), 90) which works fine as is, but doesn't work at all to just add the player rotation angle... \$\endgroup\$ – Hasen Oct 27 at 16:20
  • \$\begingroup\$ your angle clamping is broken. See stackoverflow.com/questions/42246870/… \$\endgroup\$ – Ocelot Oct 28 at 4:15
1
\$\begingroup\$

If you use a bounding circle for the player then the "dead zone" can be defined as whenever the line-of-sight ray from the gun to the target intersects with the circle.

Detecting that the gun is intersecting the player is simple, in libGDX the Intersector.intersectSegmentCircle method is useful for this.

What is harder is to rotate the gun so that it faces the target without ever rotating over the player.

One way of achieving this is to measure the signed angle between the vector from the gun to the target and from the gun to the center of the player.

Vector2 gunToTargetDirection = new Vector2(target).sub(gunPosition);
Vector2 gunToPlayerDirection = new Vector2(playerPosition).sub(gunPosition);
float gunToTargetAngle = signedAngleBetween(gunDirection, gunToTargetDirection);
float gunToPlayerAngle = signedAngleBetween(gunDirection, gunToPlayerDirection);

then you can compare the sign of those angles, and if they're the same and the angle to the player is smaller than the angle to the target then flip the sign of the rotation thus forcing the gun to rotate the long way round:

float gttaS = Math.signum(gunToTargetAngle);
float gtpaS = Math.signum(gunToPlayerAngle);
float direction;

if (gttaS == gtpaS) {
    if (Math.abs(gunToTargetAngle) < Math.abs(gunToPlayerAngle))
        direction = gttaS;
    else
        direction = -gttaS;
} else {
    direction = gttaS;
}

gunRotation += direction * maxGunTurnSpeed * Gdx.graphics.getDeltaTime();

An example of this might look like this player is green, gun is red and target is yellow. The line inside the player and the gun indicates the direction of these and the cyan line indicates that the gun has a clear line of sight to the target and will try to rotate the gun to point at it, without overlapping with the player. The gun is always mounted on the player's left shoulder.

Example graphics

In this example the gun goes passive when it cannot see the target and simply follows the player's rotation, but this could easily be changed to a more interesting behavior.

The full source code for the example above is:

package com.somepackage;

import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;

public class SomeCoolGameWithAShoulderCannonJustLikeInThePredator extends Game {
    OrthographicCamera camera;
    ShapeRenderer shapeRenderer;
    Vector2 playerPosition = new Vector2();
    Vector2 playerDirection = new Vector2(1.0f, 0.0f);
    float playerRotation = 0.0f;
    float playerSpeed = 0.0f;
    float playerSize = 32.0f;
    float gunRotation = 0.0f;
    float gunSize = 16.0f;
    Vector2 gunPosition = new Vector2();
    Vector2 gunDirection = new Vector2(1.0f, 0.0f);
    Vector2 target = new Vector2(100.0f, 0.0f);

    @Override
    public void create() {
        camera = new OrthographicCamera(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
        shapeRenderer = new ShapeRenderer();
    }

    private void renderPlayer() {
        shapeRenderer.setColor(Color.GREEN);
        shapeRenderer.circle(playerPosition.x, playerPosition.y, playerSize, 16);
        shapeRenderer.line(playerPosition.x, playerPosition.y, playerPosition.x + playerDirection.x * playerSize, playerPosition.y + playerDirection.y * playerSize);
    }

    private void renderGun(boolean targetIsObscuredByPlayer) {
        if (!targetIsObscuredByPlayer) {
            shapeRenderer.setColor(Color.CYAN);
            shapeRenderer.line(gunPosition.x, gunPosition.y, target.x, target.y);
        }
        shapeRenderer.setColor(Color.RED);
        shapeRenderer.circle(gunPosition.x, gunPosition.y, gunSize, 16);
        shapeRenderer.line(gunPosition.x, gunPosition.y, gunPosition.x + gunDirection.x * gunSize, gunPosition.y + gunDirection.y * gunSize);
    }

    private void renderTarget() {
        float targetSize = 8.0f;
        shapeRenderer.setColor(Color.YELLOW);
        shapeRenderer.circle(target.x, target.y, targetSize, 16);
    }

    private float signedAngleBetween(Vector2 a, Vector2 b) {
        Vector2 na = (new Vector2(a)).nor();
        Vector2 nb = (new Vector2(b)).nor();
        return ((float) Math.atan2(nb.y, nb.x) - (float) Math.atan2(na.y, na.x)) * MathUtils.radiansToDegrees;
    }

    private float wrapAngle(float angle) {
        angle = angle % 360;
        if (angle < 0)
            angle += 360;
        return angle;
    }

    @Override
    public void render() {
        Gdx.gl.glClearColor(0, 0, 0, 0);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        float maxPlayerSpeed = 64.0f;
        float playerAcceleration = 8.0f;
        float maxPlayerTurnSpeed = 90.0f;
        float maxGunTurnSpeed = 120.0f;

        playerSpeed *= 0.95f;
        float playerDeltaRotation = 0.0f;

        if (Gdx.input.isKeyPressed(Input.Keys.W))
            playerSpeed = Math.min(maxPlayerSpeed, playerSpeed + Gdx.graphics.getDeltaTime() * playerAcceleration);
        if (Gdx.input.isKeyPressed(Input.Keys.S))
            playerSpeed = Math.max(-maxPlayerSpeed, playerSpeed - Gdx.graphics.getDeltaTime() * playerAcceleration);
        if (Gdx.input.isKeyPressed(Input.Keys.A))
            playerDeltaRotation += Gdx.graphics.getDeltaTime() * maxPlayerTurnSpeed;
        if (Gdx.input.isKeyPressed(Input.Keys.D))
            playerDeltaRotation -= Gdx.graphics.getDeltaTime() * maxPlayerTurnSpeed;

        playerRotation += playerDeltaRotation;
        playerRotation = wrapAngle(playerRotation);

        playerDirection.set(1.0f, 0.0f).rotate(playerRotation);
        playerPosition.x += playerDirection.x * playerSpeed;
        playerPosition.y += playerDirection.y * playerSpeed;

        gunPosition.set(playerDirection).scl(playerSize + gunSize).rotate(90).add(playerPosition);

        boolean targetIsObscuredByPlayer = true;
        if (!Intersector.intersectSegmentCircle(gunPosition, target, playerPosition, playerSize * playerSize)) {
            targetIsObscuredByPlayer = false;
            Vector2 gunToTargetDirection = new Vector2(target).sub(gunPosition);
            Vector2 gunToPlayerDirection = new Vector2(playerPosition).sub(gunPosition);
            float gunToTargetAngle = signedAngleBetween(gunDirection, gunToTargetDirection);
            float gunToPlayerAngle = signedAngleBetween(gunDirection, gunToPlayerDirection);

            float gttaS = Math.signum(gunToTargetAngle);
            float gtpaS = Math.signum(gunToPlayerAngle);
            float direction;

            if (gttaS == gtpaS) {
                if (Math.abs(gunToTargetAngle) < Math.abs(gunToPlayerAngle))
                    direction = gttaS;
                else
                    direction = -gttaS;
            } else {
                direction = gttaS;
            }

            gunRotation += direction * maxGunTurnSpeed * Gdx.graphics.getDeltaTime();
        } else
            gunRotation += playerDeltaRotation;

        gunRotation = wrapAngle(gunRotation);
        gunDirection.set(1.0f, 0.0f).rotate(gunRotation);

        camera.update();


        shapeRenderer.setProjectionMatrix(camera.combined);
        shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
        renderPlayer();
        renderGun(targetIsObscuredByPlayer);
        renderTarget();
        shapeRenderer.end();
    }
}
| improve this answer | |
\$\endgroup\$
  • 1
    \$\begingroup\$ That's a completely different approach to the one I was taking but just might be the way to go. I'm gonna test it out now. Btw that's the best name for a Class ever lol. \$\endgroup\$ – Hasen Oct 28 at 3:51
  • \$\begingroup\$ It works great indeed, however the direction variable seems to constantly fluctuate between 1 and -1 even when aiming direct at the target with no movement from the player whatsoever. I'm sure that's not intended behaviour? \$\endgroup\$ – Hasen Oct 28 at 11:43
  • \$\begingroup\$ @Hasen It's not behaviour I considered for this answer, it didn't seem important for the question. In this case it happens because I am not clamping the rotation to the minimum amount of degrees required, so the gun overshoots its target and then corrects back. You can fix this by replacing gunRotation += direction * maxGunTurnSpeed * Gdx.graphics.getDeltaTime(); with gunRotation += direction * Math.min(maxGunTurnSpeed * Gdx.graphics.getDeltaTime(), Math.abs(gunToTargetAngle)); \$\endgroup\$ – bornander Oct 28 at 12:45
  • 1
    \$\begingroup\$ @Hasen Ok I see, I fixed it another way but your way seems more eloquent. Anyway it's absolutely great and works perfectly, thanks so much for your answer. \$\endgroup\$ – Hasen Oct 28 at 13:18
  • \$\begingroup\$ @Hansen Glad you found it useful! \$\endgroup\$ – bornander Oct 28 at 16:07
2
\$\begingroup\$

Think of the gun as of being parented to the player (conceptually, it inherits player's transform (rotation)). That way, you only need to worry about the local angle (in local space, it's as if the orientation of the player never changes). It's because in 2D, the angles simply add up - see the image below:

enter image description here

So you can still use

Math.max(Math.min(localAimAngle, 360), 90)

(perhaps with some adjustments to the limit values). You just have to calculate the local aim angle first, which is just:

public static float clampMountedGunAngle(float aimAngle, float playerAngle) {

    float localAimAngle = aimAngle - playerAngle;
    localAimAngle = normalizeAngle(localAimAngle);  // make sure it's in 0-360 range

    // adjust the limits as necessary
    localAimAngle = Math.max(Math.min(localAimAngle, 360), 90);
    
    float result = playerAngle + localAimAngle;
    return normalizeAngle(result);
}

// Normalizes an angle to the 0-360 range
public static float normalizeAngle(float angle) {
    return angle - (float)Math.floor(angle / 360f) * 360f;
}

P.S. For a more sophisticated handling of the behavior of the gun, see bornander's answer.

P.P.S.

Ok maybe your way can work in principle, but still it does not work at all as yet. The gun just stays fixed all the time for some reason - the whole angle range was a dead zone...not just between 360 and 90. Did you test it in an actual working program?

Here's a JavaScript snippet. I treated the gun as attached to the player, but with an independent orientation (assuming you want it to aim at something). The gunAngle in the code below is with respect to the global coordinate system, so if you don't change it, it will stay as it is. I adjusted the clamp and normalize functions to suit this setup (the two functions are at the top, after the vars):

const canvas = document.getElementById('canvas'),
context = canvas.getContext("2d");

const keyW = 87;
const keyS = 83;
const keyA = 65;
const keyD = 68;
let pressedKeys = [];

const originOffset = vec(250, 250);

let mouseLocation = vec(0, 0);

let playerAngle = 0;   // global coordinate sys (0 is up)
let playerLocation = vec(0, 0);
const playerRadius = 50;
const playerSpeed = 100;
const playerRotationSpeed = 100;

let gunAngle = 0;    // global coordinate sys (0 is up)
const gunRadius = 35;
const gunLocation_local = vec(0, playerRadius + gunRadius);
const gunDeadZone = 45;  // deg, +/- in both directions

let time = new Date().getTime();
let elapsed = 0;

function clampMountedGunAngle(aimAngle, playerAngle) {

  let localAimAngle = aimAngle - playerAngle;
  let adjustedAngle = localAimAngle + 90;
  adjustedAngle = normalizeAngle(adjustedAngle); 

  const limitMin = -180 + gunDeadZone;
  const limitMax = 180 - gunDeadZone;
  
  adjustedAngle = Math.max(Math.min(adjustedAngle, limitMax), limitMin);
  localAimAngle = adjustedAngle - 90;

  const result = playerAngle + localAimAngle;
  return normalizeAngle(result);
}

// normalize to the -180 to 180 range
function normalizeAngle(angle) {
  let offsetAngle = angle + 180;
  offsetAngle = offsetAngle - Math.floor(offsetAngle / 360) * 360;
  return offsetAngle - 180;
}

function update() {
  const newTime = new Date().getTime();
  elapsed = newTime - time;
  
  pressedKeys.forEach(handleKey);

    const aimRay = getAimRay();
  gunAngle = -radToDeg(Math.atan2(aimRay.y,aimRay.x));
  
  gunAngle = clampMountedGunAngle(gunAngle, playerAngle);
  draw();
  
  time = newTime;
  requestAnimationFrame(update);
}

function draw() {
  context.clearRect(0, 0, 500, 500);
  
  context.save();
  context.globalCompositeOperation = 'source-over';
  context.translate(originOffset.x, originOffset.y);
  context.rotate(degToRad(-90));
  
  drawPlayer(context); 
  drawGun(context); 
  drawTarget(context);
    
  context.restore();
}

function drawPlayer(context) {
  context.save();
  context.translate(playerLocation.x, playerLocation.y);
  context.rotate(degToRad(-playerAngle));
  
  context.beginPath();
  context.fillStyle = "#999";  
  context.arc(0, 0, playerRadius, 0, Math.PI); 
  context.fill();  
  
  context.beginPath();
  context.arc(0, 0, playerRadius, 0, 2*Math.PI); 
  context.stroke();
  
  context.beginPath();
  context.moveTo(-playerRadius, 0);
  context.lineTo(playerRadius+20, 0);
  context.stroke();
  
  context.restore();
}

function drawGun(context) {
  context.save();

  context.translate(playerLocation.x, playerLocation.y);
  context.rotate(degToRad(-playerAngle));
  context.translate(gunLocation_local.x, gunLocation_local.y);
  context.rotate(degToRad(playerAngle - gunAngle));
  
  context.fillStyle = "rgba(255, 0, 0, 0.2)";
  context.strokeStyle = "#FF0000";
  
  context.beginPath();
  context.moveTo(0, 0);
  context.arc(0, 0, gunRadius, 0, 2*Math.PI); 
  context.fill();
  

  context.beginPath();
  context.moveTo(0, 0);
  context.arc(0, 0, gunRadius, degToRad(-gunDeadZone), degToRad(gunDeadZone)); 
  context.fill();
  
  context.beginPath();
  context.moveTo(0, 0);
  context.arc(0, 0, gunRadius, 0, 2*Math.PI);   
  context.lineTo(gunRadius+60, 0);
  context.closePath();
  context.stroke();
  
  context.restore();
}

function drawTarget(context) {
  context.save();
  context.translate(mouseLocation.x, mouseLocation.y);
  
  context.fillStyle = "rgba(255, 0, 0, 0.2)";
  context.strokeStyle = "#FF0000";
  
  context.beginPath();
  context.moveTo(0, 0);
  context.arc(0, 0, 5, 0, 2*Math.PI); 
  context.fill();
  context.restore();
}

function getGunLocation() {
  const sinPlayer = Math.sin(degToRad(playerAngle));
  const cosPlayer = Math.cos(degToRad(playerAngle));
  const x = gunLocation_local.x;
  const y = gunLocation_local.y;
  let gunLocation = vec(
    cosPlayer * x + sinPlayer * y, 
    -sinPlayer * x + cosPlayer * y);
  gunLocation = add(playerLocation, gunLocation);
  return gunLocation;
}

function getAimRay() {
  const gunLocation = getGunLocation();
  const aimRay = add(mouseLocation, mul(-1, gunLocation));
  return aimRay;
}

function handleKey(keyCode) {
    const elapsedSec = elapsed * 0.001;
    const direction = vec(
    Math.cos(degToRad(playerAngle)), 
    -Math.sin(degToRad(playerAngle))
  );
  let delta = mul(playerSpeed * elapsedSec, direction);
  
  if ( keyCode == keyW ) {
    playerLocation = add(playerLocation, delta);
  }
  else if ( keyCode == keyS ) {
    delta = mul(-1, delta);
    playerLocation = add(playerLocation, delta);
  }
  else if ( keyCode == keyA ) {
    playerAngle += playerRotationSpeed * elapsedSec;
  }
  else if ( keyCode == keyD ) {
    playerAngle -= playerRotationSpeed * elapsedSec;
  }
}

function degToRad(deg) { return deg * Math.PI / 180; }
function radToDeg(rad) { return rad * 180 / Math.PI; }
function vec(x, y) { return ({ x, y }); }
function mul(a, vect) { return vec(a*vect.x, a*vect.y); }
function add(vecA, vecB) { return vec(vecA.x + vecB.x, vecA.y + vecB.y); }

function onKeyDown(e) {
  if (!pressedKeys.includes(e.keyCode))
    pressedKeys.push(e.keyCode);      
}

function onKeyUp(e) {
  pressedKeys = pressedKeys.filter(k => k != e.keyCode);
}

function getMousePos(canvas, evt) {
  const rect = canvas.getBoundingClientRect();
  mouseLocation = vec(evt.clientX - rect.left, evt.clientY - rect.top);
  mouseLocation = add(mouseLocation, mul(-1, originOffset));
  
  // rotate 90 deg
  const x = mouseLocation.x;
  mouseLocation.x = -mouseLocation.y;
  mouseLocation.y = x;
}

window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
canvas.addEventListener('mousemove', (e) => getMousePos(canvas, e), false);
  
requestAnimationFrame(update);
canvas {
    border: 1px solid gray;
}
<p>
  Use WASD to move (tank controls, click here first) + mouse to aim.
</p>
<canvas id="canvas" width="500" height="500">
</canvas>

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ But you can't adjust the limits because it will turn the 360 and 90 into something like 90 and 360 which obviously won't work with a min max in the same way. \$\endgroup\$ – Hasen Oct 28 at 3:27
  • \$\begingroup\$ I tested your code and it doesn't seem to work anyway. Not sure why. I updated my question with what I've tried, which works ok but the dead zone angles can't be changed. \$\endgroup\$ – Hasen Oct 28 at 3:47
  • \$\begingroup\$ @Hasen - what you've tried is not quite the same as what I've written; if you're using this hierarchical (parenting) approach, you have to transform the local angle back into the global angle before returning. Now, I've oriented the gun's local angle so that 0 is away from the player, to match the image, so that you can see how the angles add up. You don't need to normalize in the 0-360 range; e.g., it may be more convenient to use -180 to 180, so that it works better with your min/max limits (that's what I meant by adjust - e.g min -135, max 135 (in local space). \$\endgroup\$ – Filip Milovanović Oct 28 at 10:49
  • \$\begingroup\$ Yes it's not quite the same but your dead zone range doesn't update either. I don't think it will be able to update even if you added that functionality. But like I was saying, your code doesn't seem to work as it is anyway, even if it were able to update, not sure why that is. \$\endgroup\$ – Hasen Oct 28 at 11:39
  • \$\begingroup\$ @Hasen - it's not supposed to update, the dead zone is fixed, but will reorient with the player when you do playerAngle + localAimAngle. But who knows, maybe your setup is somehow different then what I had in mind, so it doesn't work as expected. \$\endgroup\$ – Filip Milovanović Oct 28 at 12:02

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.