Tutorial: First Person Camera and Controls

Hi All,

I thought I would create a little tutorial on making a first person camera and controls. First person camera and controls will open up many door ways in making many different games so being able to do this quickly and simply is very important. So this tutorial aims to be that. Something you can use time and time again and edit however you like. I’m not going to get into too many specifics.

So to start create a new empty(3D) project. I’m currently using Editor version 3.5.0, although hopefully everything should work in newer versions too.

Once created add a new “Scene” in the Assets panel:

I then add a new Folder and call it “Scripts” I then right click on the folder and create new TypeScript → NewComponent. I make three of these called cameraScript, globalVars and playerScript.
2

I then create a new terrain in the assets panel called “Terrain”.
3

Ok now that means our assets are all set up. Now we can make the changes to the scene. I first drag the terrain over to the editor window. Then create a new Capsule.

I then name the Capsule “Player” and position it above the terrain.

I then drag the “playerScript” from the Assets panel over to the object inspector. Then add two new physics components called RigidBody and SphereCollider. The rigidbody gives the character it’s physics. I’ve set mass to 70, Linear and Angular damping both to 0.2, Linear Factor (1,1,1) and Angular Factor to (0,0,0). The SphereCollider gives the object its physics shape. I set the Radius to 2.

I then select the Terrain object. I give it 2 physics components. RigidBody and TerrainCollider. I set the Rigidbody type to static as this terrain won’t be moving anywhere. The TerrainCollider uses the terrain mesh for the physics. I also had to remove the terrain pressing the x next to it and selecting the Terrain.terrain.

Last thing we need to do is select the Main Camera that should already be in your scene. Drag the “cameraScript” from the assets panel over to the object inspector. I also changed the FOV to 60.

You can then edit your terrain and make it look however you want.

So now for the script. I’ve kept it as simple as possible to follow but to make things easier I will explain that the globalVars is where you can add more global variables if you wish. I put the camera node and player node in the character and camera global variables. Which allows for these to be called from anywhere.

cameraScript

import { _decorator, Component, Input, Vec3, input,  game, Node } from 'cc';
import { globalVars } from './globalVars';
const { ccclass, property } = _decorator;

@ccclass('cameraScript')
export class cameraScript extends Component {

    /* Local Variables */

        private mouseXSensitvity: number = 8;
        private mouseYSensitvity: number = 5;
        private mousePos = new Vec3(0, 0, 0);

    /* End Local Variables */

    start() {
        globalVars.start = 0;
        globalVars.camera = this;
        document.addEventListener('pointerlockchange', this.lockChange, false);
        input.on(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

    update(deltaTime: number) {
        this.node.setPosition(globalVars.character.node.getPosition());
        if(globalVars.start == 1){
            if(this.mousePos.x >= 300 && this.mousePos.x <= 500){
                this.node.setRotationFromEuler(this.mousePos);
            } else if(this.mousePos.x < 300){
                this.node.setRotationFromEuler(300, this.mousePos.y,0);
            } else if(this.mousePos.x > 500){
                this.node.setRotationFromEuler(500, this.mousePos.y,0);
            }
            globalVars.character.node.setRotationFromEuler(new Vec3(0, this.mousePos.y, 0));
        }
    }

    onMouseUp(event:EventMouse){
        if(globalVars.start == 0){
            if (game.canvas.requestPointerLock) {
                game.canvas.requestPointerLock();
            }
        }
    }

    lockChange() {
        if (document.pointerLockElement === game.canvas ) {
          globalVars.start = 1;
        } else {
          globalVars.start = 3;
          setTimeout( () => { globalVars.start = 0; }, 1800 );
        }
    }

    onMouseMove(event:EventMouse){
        this.mousePos.x = 330 + event.getLocation().y/this.mouseXSensitvity;
        this.mousePos.y = -event.getLocation().x/this.mouseYSensitvity;
    }
}

globalVars

import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('globalVars')
export class globalVars extends Component {
    
    /* Global Variables */

        public character;
        public camera;
        public start = 0;

    /* End Global Variables */ 
}

playerScript

import { _decorator, Component, Node, game, input, Input, KeyCode, EventKeyboard, Vec3, SphereCollider, ICollisionEvent, RigidBody } from 'cc';
import { globalVars } from './globalVars';
const { ccclass, property } = _decorator;

@ccclass('PlayerScript')
export class PlayerScript extends Component {

    /* Local Variables */

        private movementSpeed = 3;
        private jumpStrength = 300;
        private crouch = 0;
        private moveForward = 0;
        private moveBackward = 0;
        private moveLeft = 0;
        private moveRight = 0;
        private setSpeed = 0.12;
        private maxSpeed = 0;
        private speed = 1;
        private grounded = false;
        private collider: SphereCollider = null!;
        private playerHeight = 2;

    /* End Local Variables */
    
    start() {
        globalVars.character = this;
        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this.onKeyUp, this)
        this.node.getComponent(SphereCollider).on('onCollisionStay', this.OnCollisionStay, this);
        this.node.getComponent(SphereCollider).on('onCollisionExit', this.OnCollisionExit, this);
        this.node.getComponent(SphereCollider).radius = this.playerHeight;
        this.maxSpeed = this.setSpeed;
    }

    OnCollisionStay(event: ICollisionEvent) {
        event.contacts.forEach(contact => {
            let out: Vec3 = new Vec3();
            contact.getWorldNormalOnB(out);
            if (Vec3.angle(out, Vec3.UP) < 60) {
                this.grounded = true;
            }
        });
    }

    OnCollisionExit() {
        this.grounded = false;
    }

    onKeyDown(event: EventKeyboard){
        switch(event.keyCode) {
            case KeyCode.KEY_A: {
                this.moveLeft = 1;
            break;}

            case KeyCode.KEY_W: {
                this.moveForward = 1;
            break;}

            case KeyCode.KEY_D: {
                this.moveRight = 1;
            break;}

            case KeyCode.KEY_S: {
                this.moveBackward = 1;
            break;}

            case KeyCode.SHIFT_LEFT: {
                this.speed = 1.1;
            break;}

            case KeyCode.KEY_C: {
                if(this.crouch == 0){
                    this.crouch = 1;
                    this.node.getComponent(RigidBody).clearForces();
                    this.node.getComponent(SphereCollider).radius = this.playerHeight/1.4;
                    this.maxSpeed = this.setSpeed/1.4;
                } else if(this.crouch == 1){
                    this.crouch = 0;
                    this.node.getComponent(SphereCollider).radius = this.playerHeight;
                    this.maxSpeed = this.setSpeed;
                }
            break;}

            case KeyCode.SPACE: {
                if(this.grounded && this.crouch == 0){
                    this.node.getComponent(RigidBody).applyLocalForce(new Vec3(0,this.jumpStrength*this.node.getComponent(RigidBody).mass,0), new Vec3(0,10,0));
                }
            break;}
        }
    }

    onKeyUp(event: EventKeyboard){
        switch(event.keyCode) {
            case KeyCode.KEY_A: {
                this.moveLeft = 0;
            break;}

            case KeyCode.KEY_W: {
                this.moveForward = 0;
            break;}

            case KeyCode.KEY_D: {
                this.moveRight = 0;
            break;}

            case KeyCode.KEY_S: {
                this.moveBackward = 0;
            break;}

            case KeyCode.SHIFT_LEFT: {
                this.speed = 1;
            break;}
        }
    }

    update(deltaTime: number) {
            if(this.moveForward && this.moveLeft){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(-((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed,0,-((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed));
            } else if(this.moveForward && this.moveRight){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed,0,-((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed));
            } else if(this.moveBackward && this.moveLeft){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(-((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed,0,((this.maxSpeed/4)*this.node.getComponent(RigidBody).mass)*this.speed));
            } else if(this.moveBackward && this.moveRight){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed,0,((this.maxSpeed/4)*this.node.getComponent(RigidBody).mass)*this.speed));
            } else if(this.moveForward){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(0,0,-(this.maxSpeed*this.node.getComponent(RigidBody).mass)*this.speed));
            } else if(this.moveBackward){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(0,0,((this.maxSpeed/2)*this.node.getComponent(RigidBody).mass)*this.speed));
            } else if(this.moveLeft){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3(-(this.maxSpeed*this.node.getComponent(RigidBody).mass)*this.speed,0,0));
            } else if(this.moveRight){
                this.node.getComponent(RigidBody).applyLocalImpulse(new Vec3((this.maxSpeed*this.node.getComponent(RigidBody).mass)*this.speed,0,0));
            }
    }
}

Once you replace your scripts with the above. Save the scripts and save the scene in the editor. Then test the project and you should now have first person controls with AWSD movement, crouch (c key), sprint (Hold left shift) and jump (spacebar). When you click on the window the mouse will disappear. Press the escape key to see the mouse again.

Any questions or additions etc. Please let me know.

Thanks,
-iDev

7 Likes

I’m having a problems with the controller, I’m not sure if it’s the terrain or the player but the collisions are weird. I took a look at the scripts to see if there is a problem, and there is a lot of underlined text, the issue is that when I run the project, I go through the ground. You can ask me for screenshots but I’m a bit in a hurry so, mind telling me if I did anything wrong? I went over the process over and over but still didn’t really work.

Hi, Make sure your player capsule is above the terrain because if your capsule collider is a little through the terrain you will fall through it. So just move it a little above so it’ll fall onto the terrain. If that doesn’t work, attempt trying with a box collider instead of a terrain collider below the player.

I’m sorry, but non of those work. Mind sending a file or a link to the file of the project so I can compare? I’m just confused.

First Person Demo.zip (3.3 MB)

Hopefully it helps!

Thank you soooo much!!!

I’ve also released my whole FPS project source code here if you want more of an example:

Hi, good morning, I wanted to ask you what I should modify so that it can be executed in a Windows window, since the camera script does not recognize me in the window

Hi,

I’ve not tried, although:

game.canvas.requestPointerLock();

This is the part that locks the mouse. It does it on click as it’s expecting you to be outside of the game but maybe do it on game launch instead?

That also being said, maybe the mouse is already locked in the window (again not tried so not sure). The variable in start in camera script:

start() {
        globalVars.start = 0;
        globalVars.camera = this;
        document.addEventListener('pointerlockchange', this.lockChange, false);
        input.on(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

Sets the globalVars.start to 0. Start is the variable that needs to be 1 before any mouse movement is taken into consideration. That sets to 1 on first click but you could set this to 1 to see if that solves your problem.

Let me know if that helps.

Thanks,
-iDev

First Person Demo.zip (3.3 MB)

I’ve now updated the script to set the position of the player instead of using physics to move the player. Making the player control more conventionally.

Use this for your player script:

import { _decorator, Component, Node, game, input, Input, KeyCode, EventKeyboard, Vec3, CapsuleCollider, ICollisionEvent, RigidBody } from 'cc';
import { globalVars } from './globalVars';
const { ccclass, property } = _decorator;

@ccclass('PlayerScript')
export class PlayerScript extends Component {

    /* Local Variables */

        private movementSpeed = 3;
        private jumpStrength = 400;
        private crouch = 0;
        private moveForward = 0;
        private moveBackward = 0;
        private moveLeft = 0;
        private moveRight = 0;
        private setSpeed = 8;
        private maxSpeed = 0;
        private speed = 1;
        private grounded = false;
        private collider: CapsuleCollider = null!;
        private playerHeight = 2;
        private nodePos: Vec3 = new Vec3();
        private nodeDeltaPos: Vec3 = new Vec3(0, 0, 0);

    /* End Local Variables */
    
    start() {
        globalVars.character = this;
        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this.onKeyUp, this)
        this.node.getComponent(CapsuleCollider).on('onCollisionStay', this.OnCollisionStay, this);
        this.node.getComponent(CapsuleCollider).on('onCollisionExit', this.OnCollisionExit, this);
        this.node.getComponent(CapsuleCollider).radius = this.playerHeight;
        this.maxSpeed = this.setSpeed;
    }

    OnCollisionStay(event: ICollisionEvent) {
        event.contacts.forEach(contact => {
            let out: Vec3 = new Vec3();
            contact.getWorldNormalOnB(out);
            if (Vec3.angle(out, Vec3.UP) < 60) {
                this.grounded = true;
            }
        });
    }

    OnCollisionExit() {
        this.grounded = false;
    }

    onKeyDown(event: EventKeyboard){
        switch(event.keyCode) {
            case KeyCode.KEY_A: {
                this.moveLeft = 1;
            break;}

            case KeyCode.KEY_W: {
                this.moveForward = 1;
            break;}

            case KeyCode.KEY_D: {
                this.moveRight = 1;
            break;}

            case KeyCode.KEY_S: {
                this.moveBackward = 1;
            break;}

            case KeyCode.SHIFT_LEFT: {
                this.setSpeed = 15;
            break;}

            case KeyCode.KEY_C: {
                if(this.crouch == 0){
                    this.crouch = 1;
                    this.node.getComponent(RigidBody).clearForces();
                    this.node.getComponent(CapsuleCollider).radius = this.playerHeight/1.4;
                    this.maxSpeed = this.setSpeed/1.4;
                } else if(this.crouch == 1){
                    this.crouch = 0;
                    this.node.getComponent(CapsuleCollider).radius = this.playerHeight;
                    this.maxSpeed = this.setSpeed;
                }
            break;}

            case KeyCode.SPACE: {
                if(this.grounded && this.crouch == 0){
                    this.node.getComponent(RigidBody).applyLocalForce(new Vec3(0,this.jumpStrength*this.node.getComponent(RigidBody).mass,0), new Vec3(0,10,0));
                }
            break;}
        }
    }

    onKeyUp(event: EventKeyboard){
        switch(event.keyCode) {
            case KeyCode.KEY_A: {
                this.moveLeft = 0;
            break;}

            case KeyCode.KEY_W: {
                this.moveForward = 0;
            break;}

            case KeyCode.KEY_D: {
                this.moveRight = 0;
            break;}

            case KeyCode.KEY_S: {
                this.moveBackward = 0;
            break;}

            case KeyCode.SHIFT_LEFT: {
                this.setSpeed = 8;
            break;}
        }
    }

    update(deltaTime: number) {
        this.nodePos = this.node.getPosition();
        if(this.moveForward){
            Vec3.add(this.nodePos, this.nodePos, this.node.forward.clone().multiplyScalar(this.setSpeed * deltaTime));
            this.node.setPosition(this.nodePos);
        }
        if(this.moveBackward){
            Vec3.add(this.nodePos, this.nodePos, this.node.forward.clone().multiplyScalar(-this.setSpeed * deltaTime));
            this.node.setPosition(this.nodePos);
        }
        if(this.moveLeft){
            Vec3.add(this.nodePos, this.nodePos, this.node.right.clone().multiplyScalar((-this.setSpeed)/2 * deltaTime));
            this.node.setPosition(this.nodePos);
        }
        if(this.moveRight){
            Vec3.add(this.nodePos, this.nodePos, this.node.right.clone().multiplyScalar((this.setSpeed)/2 * deltaTime));
            this.node.setPosition(this.nodePos);
        }
    }
}

You will also need to use a Capsule Collider instead of a sphere collider as that was also changed.

this is very helpful, i wish i had found this yesterday lol. thank you so much!

Glad it helped. It was a huge headache for me too at the time.

There’s a lot of interesting things to be found in the Dungeon 3D demo too, so it’s worth checking that out too. Look at the scripts here: Dungeon-3D/assets/Scripts at main · iDev-Games/Dungeon-3D · GitHub

will take a look, thank you!