Twin stick shooter controls for gamepad

Any insight on how to make a twin stick shooter for the gamepad?

I’ve tried to backwards engineer some tutorials I saw done in Unity and other game engines but not sure how to get it to work perfectly.

I did something along these lines but there is some weird jittering and it’s not working perfectly. You can see in the Gif below. That stuttering is what I’m experiencing.

TwinShooterControls Scene
https://playcanvas.com/project/913883/overview/testscripting

var TwinShooter = pc.createScript('twinShooter');

//Initialize
TwinShooter.prototype.initialize = function () {
    pc.app.gamepads.deadZone = 0;
    this.rotationAngle = new pc.Quat();
    this.horizontalValue = 0;
    this.verticalValue = 0;
};

//Update
TwinShooter.prototype.update = function (dt) {
    let horizontalValue = this.app.gamepads.getAxis(pc.PAD_1, pc.PAD_L_STICK_X);
    let verticalValue = this.app.gamepads.getAxis(pc.PAD_1, pc.PAD_L_STICK_Y);


    if (horizontalValue != 0) {
        this.horizontalValue = this.app.gamepads.getAxis(pc.PAD_1, pc.PAD_L_STICK_X);
    }
    if (verticalValue != 0) {
        this.verticalValue = this.app.gamepads.getAxis(pc.PAD_1, pc.PAD_L_STICK_Y);
    }

    let degrees = calcAngleDegrees(this.horizontalValue, this.verticalValue);
    this.entity.setEulerAngles(0, degrees, 0);

};

function calcAngleDegrees(x, y) {
    return Math.atan2(x, y) * 180 / Math.PI;
}

20220424_1650850334_1859

Hi @Robotpencil,

You are close! Here is a try on that script using deltas to rotate the entity, instead of calculating the final angle myself. It also includes a radial dead zone check, taken from an engine example script (link below).

Your corrected script:

var TwinShooter = pc.createScript('twinShooter');

TwinShooter.attributes.add('deadZoneLow', {
    title: 'Low Dead Zone',
    description: 'Radial thickness of inner dead zone of pad\'s joysticks. This dead zone ensures that all pads report a value of 0 for each joystick axis when untouched.',
    type: 'number',
    min: 0,
    max: 0.4,
    default: 0.2
});

TwinShooter.attributes.add('deadZoneHigh', {
    title: 'High Dead Zone',
    description: 'Radial thickness of outer dead zone of pad\'s joysticks. This dead zone ensures that all pads can reach the -1 and 1 limits of each joystick axis.',
    type: 'number',
    min: 0,
    max: 0.4,
    default: 0.2
});

TwinShooter.attributes.add('turnSpeed', {
    title: 'Turn Speed',
    description: 'Maximum turn speed in degrees per second',
    type: 'number',
    default: 150
});

// range from 0 to 1.
function applyRadialDeadZone(pos, remappedPos, deadZoneLow, deadZoneHigh) {
    var magnitude = pos.length();

    if (magnitude > deadZoneLow) {
        var legalRange = 1 - deadZoneHigh - deadZoneLow;
        var normalizedMag = Math.min(1, (magnitude - deadZoneLow) / legalRange);
        var scale = normalizedMag / magnitude;
        remappedPos.copy(pos).scale(scale);
    } else {
        remappedPos.set(0, 0);
    }
}

TwinShooter.prototype.initialize = function () {

    this.remappedPos = new pc.Vec2();

    this.leftStick = {
        center: new pc.Vec2(),
        pos: new pc.Vec2()
    };
    this.rightStick = {
        center: new pc.Vec2(),
        pos: new pc.Vec2()
    };
};

TwinShooter.prototype.update = function (dt) {

    const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];

    for (var i = 0; i < gamepads.length; i++) {
        const gamepad = gamepads[i];

        // Only proceed if we have at least 2 sticks
        if (gamepad && gamepad.mapping === 'standard' && gamepad.axes.length >= 4) {

            let lookLeftRight = 0.0;

            this.leftStick.pos.set(gamepad.axes[0], gamepad.axes[1]);
            applyRadialDeadZone(this.leftStick.pos, this.remappedPos, this.deadZoneLow, this.deadZoneHigh);

            lookLeftRight += -this.remappedPos.x * this.turnSpeed * dt;

            this.rightStick.pos.set(gamepad.axes[2], gamepad.axes[3]);
            applyRadialDeadZone(this.rightStick.pos, this.remappedPos, this.deadZoneLow, this.deadZoneHigh);

            lookLeftRight += -this.remappedPos.x * this.turnSpeed * dt;

            this.entity.rotate(0, lookLeftRight, 0);
        }
    }
};

1 Like

This unfortunately doesn’t work as I was intending.

So when playing a twin stick shooter, you have one stick that will control basic movement and one that controls aiming direction. Depending on whether the game is 2d side scroller or 3rdperson overhead camera, both controls are highly Responsive to the player’s intention.

This simple rotation I actually am aware how to do, but it is not the movement type I’m looking for. I want it so that if I decided to point up to the right, I should already be there. Not waiting for it to rotate there. That type of rotation reminds me of the movement of a tank canon versus, a person with a shot gun doing a 180 to shoot a zombie behind them as they are running backwards at the same time. Do you feel me lol. I’ll send some examples for how this rotation should work. But the general gist is that it must be responsive and accurate.

Now some of these examples do use a keyboard and mouse, but I’m trying to utilize the gamepad. Also included other tutorials I saw. Either way thanks a ton for the help! Just looking for a different set up I suppose.
20220425_1650889918_1864

Games>>

Tutorials>>

Don’t have an example on hand, but one way I would do it is:

  • Get the position of the player into screen space
  • Create new a screen position using the right stick input relative to the player screen position
  • Project that screen position to world
  • Have the player look at the projected world position
1 Like

Ah makes sense, got it! You may be able to do that easily using a rotation vector or by calculating the rotation angle. Similar to what you were doing but without the issue you had.

I’ll give it a try later myself and let you know.

Here is a quick try using vectors/lookAt @Robotpencil:

var TwinShooter = pc.createScript('twinShooter');

TwinShooter.attributes.add('deadZoneLow', {
    title: 'Low Dead Zone',
    description: 'Radial thickness of inner dead zone of pad\'s joysticks. This dead zone ensures that all pads report a value of 0 for each joystick axis when untouched.',
    type: 'number',
    min: 0,
    max: 0.4,
    default: 0.2
});

TwinShooter.attributes.add('deadZoneHigh', {
    title: 'High Dead Zone',
    description: 'Radial thickness of outer dead zone of pad\'s joysticks. This dead zone ensures that all pads can reach the -1 and 1 limits of each joystick axis.',
    type: 'number',
    min: 0,
    max: 0.4,
    default: 0.2
});

TwinShooter.attributes.add('moveSpeed', {
    title: 'Move Speed',
    description: 'Maximum move speed',
    type: 'number',
    default: 1
});

// range from 0 to 1.
function applyRadialDeadZone(pos, remappedPos, deadZoneLow, deadZoneHigh) {
    var magnitude = pos.length();

    if (magnitude > deadZoneLow) {
        var legalRange = 1 - deadZoneHigh - deadZoneLow;
        var normalizedMag = Math.min(1, (magnitude - deadZoneLow) / legalRange);
        var scale = normalizedMag / magnitude;
        remappedPos.copy(pos).scale(scale);
    } else {
        remappedPos.set(0, 0);
    }
}

TwinShooter.prototype.initialize = function () {

    this.vec = new pc.Vec3();
    this.remappedPos = new pc.Vec2();

    this.leftStick = {
        center: new pc.Vec2(),
        pos: new pc.Vec2()
    };
    this.rightStick = {
        center: new pc.Vec2(),
        pos: new pc.Vec2()
    };
};

TwinShooter.prototype.update = function (dt) {

    const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];

    for (var i = 0; i < gamepads.length; i++) {
        const gamepad = gamepads[i];

        // Only proceed if we have at least 2 sticks
        if (gamepad && gamepad.mapping === 'standard' && gamepad.axes.length >= 4) {

            this.leftStick.pos.set(gamepad.axes[0], gamepad.axes[1]);
            applyRadialDeadZone(this.leftStick.pos, this.remappedPos, this.deadZoneLow, this.deadZoneHigh);

            const moveX = -this.remappedPos.x * this.moveSpeed * dt;
            const moveY = -this.remappedPos.y * this.moveSpeed * dt;

            const moveDir = new pc.Vec3(-moveX, 0, -moveY);

            this.entity.translate(moveDir);

            this.rightStick.pos.set(gamepad.axes[2], gamepad.axes[3]);
            applyRadialDeadZone(this.rightStick.pos, this.remappedPos, this.deadZoneLow, this.deadZoneHigh);

            const lookX = -this.remappedPos.x;
            const lookY = -this.remappedPos.y;

            const lookDir = new pc.Vec3(lookX, 0, lookY);

            // --- sensitivity
            if (lookDir.length() > 0.5) {

                const currentPos = this.entity.getPosition();
                const lookAtPos = this.vec.copy(currentPos).add(lookDir);
                this.entity.lookAt(lookAtPos);
            }
        }
    }
};
1 Like

Ahh sweet I’ll try when im home in the afternoon. Still at the hospital. Lol

But this looks great!

At a glance this is great, but how accurate is the look at direction. I found that there were pockets of dead zones on the hardware it self.

Also can you test simple look directions, like 0, 45 , 90 etc

I will test this code when Im home but yea this is n
Fantastic and im grateful for the assistance

There is a radial dead zone method, taken from the engine example scripts. You can tweak the parameters or fully remove it. And play with sensitivity threshold, you will find the comment.

For that this solution won’t work, since it uses a direction vector to look at the input direction.

You will have to grab the final angle, round it the closest step angle and reapply it.