Criando um jogo de guerra nas estrelas em javascript usando a biblioteca p5.js

Jogo simples de guerra espacial desenvolvido em javascript. Esse jogo usa cálculos de física para simular efeitos de atrito e inércia.

Postado em 15 outubro 2022

Atualizado em 15 outubro 2022



Introdução do projeto de guerra nas estrelas

Jogo desenvolvido em p5.js, utilizando a linguagem de javascript. O p5.js é uma biblioteca de fácil utilização e permite também o desenvolvimento de jogos em três dimensões (3D).

O nome dado ao projeto chama-se “guerra nas estrelas”. Esse projeto é bastante simples, porém tem um certo ambiente devido aos efeitos sonoros adicionados. A programação desse projeto usa cálculos de física para realizar determinados processos, como movimentação da nave. Esses efeitos dão um clima mais realístico, como a inércia.

Além da movimentação de nave, esse jogo possui muitos outras funcionalidades, como colisões de objetos, controle de cenas e ondas de inimigos.

Ferramentas utilizadas

O jogo “projeto nas estrelas” será executada em javascript. Basta importar a biblioteca p5.js para seguirmos adiante.

Ferramenta Papel
Javascript Linguagem de programação
HTML Usado para exibir o jogo
p5.js Biblioteca de desenvolvimento de jogos
Node.js Requerimento para NPM
npm Gerenciamento de pacotes Javascript

Preparando o diretório

Obs: O OS que será utilizado será o macOS Monterey, portanto poderá haver diferença nos comandos para usuários que utilizam outro tipo de OS, como Windows.

Primeiramente, criamos o diretório raiz do projeto.

mkdir GuerraNasEstrelas

No diretório “GuerraNasEstrelas”, armazenaremos todos os arquivos do jogo.

Em seguida, iniciamos o npm para gerenciar as bibliotecas e instalamos o p5.js.

cd GuerraNasEstrelas
npm init
npm install p5

Após instalar a biblioteca, criaremos um diretório para armazenar recursos como imagens e sons, e outro diretório para armazenar os arquivos de javascript, responsáveis pela lógica do jogo.

mkdir resources
mkdir core

Uma vez que a estrutura dos diretórios foi definida, criamos o arquivo principal do jogo e damos o nome de “index.html”, responsável por invocar a biblioteca e exibir o jogo.

touch index.html

Após criar o arquivo acima, editaremos o seu interior com o código abaixo:

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="node_modules/p5/lib/p5.min.js"></script>
    <script src="node_modules/p5/lib/addons/p5.sound.min.js"></script>
</head>
<body>
    <main>
	    <h2>Guerra nas estrelas</h2>
	    <div id="sketch-holder">
	    </div>
    </main>
    <script src="core/main.js"></script>
</body>
</html>

Finalmente, instalamos a biblioteca para montar o servidor e vermos o resultado.

npm install http-server
http-server

O servidor deve ser estabelecido com o comando acima, porém ainda não criamos o arquivo “main.js” que será o arquivo onde toda a lógica do jogo será escrita.

Criando a lógica do jogo

Antes de começar criando a lógica do jogo é preciso entender os conceitos básicos da biblioteca p5.js. O p5.js tem duas funções essenciais para o seu funcionamento:

  • setup
  • draw

A função “setup” só será chamada uma vez antes do jogo ser iniciado. A função “draw” irá ser chamada constantemente durante o jogo, sendo responsável por atualizar a interface do usuário constantemente.

Em seguida, abrimos o “main.js”, localizado no diretório “core”.

Importando imagens, sons e fontes para o jogo

A biblioteca p5.js disponibiliza a função “preload”, responsável por importar arquivos externos. Enquanto os arquivos não forem totalmente carregados, o jogo permanecerá na situação de espera.

let shotSoundEffect;
let explosionSoundEffect;
let backMusic;
let gameFont;
let startScreenAsset;

function preload() {
    backMusic = loadSound('resources/lost_in_space.mp3');
    shotSoundEffect = loadSound('resources/shot.wav');
    explosionSoundEffect = loadSound('resources/explosion.wav');
    gameFont = loadFont('resources/font.ttf');
    startScreenAsset = loadImage('resources/start_screen.png');
}

Arquivos de som, imagem e fonte serão armazenados em variáveis globais, assim é possível acessar essas variáveis de qualquer local quando necessário.

Após a adição dos recursos utilizados no jogo, na função “setup” adicionamos as configurações como tamanho da tela, instância da câmera, tamanho da fonte e posição dos textos:

...
let  cam;

function setup() {
    let canvas = createCanvas(700, 600, WEBGL);
    canvas.parent('sketch-holder');
    cam = createCamera();

    textFont(gameFont);
    textSize(width / 4);
    textAlign(CENTER, CENTER);
}

Estabelecendo os estados do jogo

O jogo terá quatro estados:

  1. Tela inicial
  2. Tela de jogo
  3. Tela de game over
  4. Tela de vitória

Cada estado terá um número atribuído, que será mantido por uma única variável. Em seguida, fazemos a implementação do da função “draw”:

...
let  gameState  =  1; // 1: start, 2: gameplay, 3: gameover, 4: gameclear

function draw() {
    background(40, 40, 40);

    if (gameState == 1) {
        gameStartScreen();
        return;
    }

    if (gameState == 2) {
        gamePlayScreen();
        return;
    }

    if (gameState == 3) {
        gameOverScreen();
        return;
    }

    if (gameState >= 4) {
        gameClearScreen();
        return;
    }
}

O algoritmo acima é bastante simples. Caso o número do estado atual seja o dentro da condição, a interface do usuário será atualizada e retornará nulo, escapando da função.

As funções dentro das condições ainda serão implementadas.

Implementação da tela inicial

A tela inicial será a primeira tela a ser exibida após o carregamento dos recursos.

..
const spaceKey = 32;
function gameStartScreen() {
    image(startScreenAsset, width / 2 * -1, height / 2 * -1, width, height);
    textSize(25);
    text('Aperte espaço para iniciar o jogo', 0, height / 4);

    if (keyIsDown(spaceKey)) {
        gameState = 2;
    }
}

A função acima é repetida constantemente até que o usuário aperte a tecla “espaço” no teclado. Uma vez que o usuário aperta tecla, o estado do jogo é trocado para o número dois, iniciando o jogo.

As três primeiras linhas são respectivamente exibição de imagem, tamanho do texto e texto a ser exibido. O cálculo realizado na exibição da imagem tem como objetivo centralizar a imagem e deixar na mesma medida da tela do jogo.

tela inicial do jogo guerra nas estrelas

Implementação da tela principal (gameplay)

A tela principal é a tela de ação, onde vários comandos são executados.

function gamePlayScreen() {
    if (!backMusic.isPlaying()) {
        backMusic.play();
    }
}

A guerra nas estrelas é um jogo que o usuário terá que avançar por ondas. Conforme o usuário vai avançando, mais difícil torna-se o jogo.

Antes de implementar as ondas, implementaremos a nave do usuário.

Implementando o usuário

O usuário terá os seguintes atributos:

  • Vida
  • Posição
  • Velocidade
  • Aceleração
  • Nitro
  • Tiros

Outros atributos também serão atribuídos, porém os atributos acima serão os mais importantes.

Criaremos uma classe que representará o usuário. Nessa classe serão escritas todas as lógicas envolvendo o usuário, como tempo entre tiros, movimentação da nave, freio e exibição.

class Spaceship {
    constructor(nitro) {
        this.hp = new Hp(100);
        this.bHeigth = -50;
        this.bWidth = 20;
        this.position = createVector(0, height / 2 + this.bHeigth);
        this.velocity = createVector(0, 0);
        this.acceleration = createVector(0, 0);
        this.accelerationPower = 0.2;

        // Nitro
        this.nitro = nitro;

        // Bullets
        this.bullets = [];
        this.currentTime = 0;
        this.timeBetweenShots = 12;
    }
}

Acima, definimos todas as variáveis do usuário. Porém, ainda não implementamos a classe “Hp” que está sendo atribuída a variável que representa a vida do usuário. A classe “Hp” será implementado depois.

Implementação da movimentação do usuário

A movimentação da nave será feita através das teclas de direção do teclado. Além da movimentação, aceleração e freio serão adicionados.

A função abaixo será implementada dentro da classe “spacheship”.

..
const sKey = 83;
const aKey = 65;

keyboardCommands() {
    // Nitro
    let accelerationPower = this.accelerationPower;

    if (keyIsDown(aKey)) {
        accelerationPower += this.nitro.active();
    } else {
        this.nitro.isActived = false;
    }

    // Keys Pressed
    if (keyIsDown(UP_ARROW)) {
        this.acceleration.y -= accelerationPower;
    }

    if (keyIsDown(DOWN_ARROW)) {
        this.acceleration.y += accelerationPower;
    }

    if (keyIsDown(RIGHT_ARROW)) {
        this.acceleration.x += accelerationPower;
    }

    if (keyIsDown(LEFT_ARROW)) {
        this.acceleration.x -= accelerationPower;
    }

    // Brake
    if (keyIsDown(sKey)) {
        this.brake();
    }
}

As direções em letra maiúscula são variáveis globais já definidas na biblioteca p5.js. Após apertarmos as teclas de direção, o usuário não se movimentará imediatamente. O tempo para mudar de direção vai depender da aceleração atual da nave.

Em seguida, implementamos a função de “freio” do usuário:

brake() {
    let verticalBrakingVelocity = this.calculateBrakingVelocity(this.velocity.y);
    let horizontalBrakingVelocity = this.calculateBrakingVelocity(this.velocity.x);

    if (this.velocity.y > 0) {
        this.velocity.y -= verticalBrakingVelocity;
    } else if (this.velocity.y < 0) {
        this.velocity.y += verticalBrakingVelocity;
    }

    if (this.velocity.x > 0) {
        this.velocity.x -= horizontalBrakingVelocity;
    } else if (this.velocity.x < 0) {
        this.velocity.x += horizontalBrakingVelocity;
    }
}

calculateBrakingVelocity(velocity) {
	let coefficient = 0.1;
    return velocity * velocity / 2 * coefficient * gravity;
}

As condições da função acima só funcionam quando a nave não está em movimento, caso a nave esteja parada, o freio não irá ter nenhum efeito.

Logo depois, implementamos a função “drive”, responsável por movimentar a nave de fato baseando-se na aceleração e adicionamos a condição necessária para parar a nave quando esta prestes a sair da tela.

drive() {
    this.keyboardCommands();

    // Add Acceleration
    this.velocity.add(this.acceleration);

    // Affect position with velocity
    this.position.add(this.velocity);
    this.acceleration.mult(0);

    // Reload Nitro
    if (!this.nitro.isActived) {
        this.nitro.reload();
    }

    // Edges
    if (this.position.x < (width / 2 - this.bWidth) * -1) {
        this.position.x = (width / 2 - this.bWidth) * -1;
        this.velocity.mult(0);
    }

    if (this.position.x > width / 2 - this.bWidth) {
        this.position.x = width / 2 - this.bWidth;
        this.velocity.mult(0);
    }

    if (this.position.y < (height / 2 + this.bHeigth) * -1) {
        this.position.y = (height / 2 + this.bHeigth) * -1;
        this.velocity.mult(0);
    }

    if (this.position.y > height / 2 + this.bHeigth) {
        this.position.y = height / 2 + this.bHeigth;
        this.velocity.mult(0);
    }
}

A função acima também invoca a função “keyboardCommands”.

Finalmente, adicionamos a função de exibição da nave do usuário:

display(enableControls = true) {
    if (enableControls) {
        this.drive();
        this.fire();
        this.hp.display();
    }

    push();
    translate(this.position.x, this.position.y);
    cone(this.bWidth, this.bHeigth);
    pop();
}

Assim, concluímos o usuário por enquanto.

Classe de gerenciamento de vida de usuário

A classe HP é responsável por exibir e gerenciar a quantidade restante que o usuário tem de vida.

class Hp {
    constructor(max) {
        this.bWidth = 20;
        this.bHeight = 100;
        this.left = max;
        this.max = max;
    }

    decrease(damage) {
        if (this.left > 0) {
            this.left -= damage;
        }

        if (this.left < 1) {
            gameState = 3;
        }
    }

    display() {
        push();
        normalMaterial();
        fill(255, 0, 0, 60);
        stroke(255, 0, 0);
        let yOrigin = convertRange(this.max, 0, height / 2 - this.bHeight - 10, (height / 2 - this.bHeight - 10) + this.bHeight, this.left);
        let currentHeight = convertRange(0, this.max, 0, this.bHeight, this.left);
        rect(width / 2 - this.bWidth - 10, yOrigin, this.bWidth, currentHeight);
        pop();
    }
}

A função “decrease” acima é responsável por diminuir a vida do usuário em situações de dano e por trocar a situação atual do jogo para três (Game over) caso o usuário não tenha mais HP disponível.

Classe de gerenciamento do nitro da nave

Assim como a barra de HP, o nitro também terá uma barra para representar a sua quantidade disponível dentro da nave do usuário.

class Nitro {
    constructor(power) {
        this.bWidth = 20;
        this.bHeight = 100;
        this.power = power;
        this.left = 3;
        this.isActived = false;
    }

    active() {
        if (this.left > 0) {
            this.isActived = true;
            this.left -= 0.1;
            return this.power;
        }

        return 0;
    }

    reload() {
        if (this.left < 3 && !this.isActived) {
            this.left += 0.1;
        }
    }

    display() {
        push();
        normalMaterial();
        fill(0, 191, 255, 60);
        stroke(0, 191, 255);
        let yOrigin = convertRange(3, 0, height / 2 - this.bHeight - 10, (height / 2 - this.bHeight - 10) + this.bHeight, this.left);
        let currentHeight = convertRange(0, 3, 0, this.bHeight, this.left);
        rect(width / 2 - (this.bWidth * 2) - 20, yOrigin, this.bWidth, currentHeight);
        pop();
    }
}

barras de hp e nitro do jogo guerra nas estrelas

Definição dos tiros

Ao apertar a tecla espaço, tiros são disparados pelo usuário. Cada um desses tiros serão gerenciados por uma uma instância da classe “Bullet”.

class Bullet {
    constructor(position, size, speed, isPlayer = true, routeVec = null) {
        this.position = position.copy();
        this.size = size;
        this.speed = speed;
        this.isPlayer = isPlayer;
        this.damage = 5;
        this.routeVec = routeVec;
    }

    travelThroughSpace() {
        if (this.isPlayer) {
            this.position.sub(createVector(0, this.speed));
            return;
        }

        if (!this.routeVec) {
            this.position.add(createVector(0, this.speed));
            return;
        }

        this.position.add(this.routeVec);
    }
    ...
}

Atributos como direção e autor dos disparos serão definidos ainda no construtor da classe. Assim, podemos usar essa mesma classe para o usuário e os inimigos.

Definição do inimigo

Também criaremos a classe representando a nave inimiga.

class Enemy {
    constructor(hp, position, level, bWidth, bHeight, speed, shotSpeed = 2) {
        this.hp = hp;
        this.position = position;
        this.bWidth = bWidth;
        this.bHeight = bHeight;
        this.speed = speed;
        this.shotSpeed = shotSpeed;
        this.level = level;

        this.bullets = [];
        this.currentTime = 0;
        this.timeBetweenShots = 40;

        this.angleY = 0;
    }
}

A estrutura do inimigo e do usuário é bastante parecida. Os tiros disparados pelo inimigo será gerenciado dentro da classe “enemy” pela variável “bullets”.

Implementação dos disparos do inimigo e do usuário

A implementação dos disparos do usuário são realizados pela função “fire”. Nessa função serão controlados as colisões dos disparos com os inimigos, remoção dos tiros após eles desaparecem da tela e criação de novos disparos caso o usuário aperte a tecla “espaço”.

fire() {
    if (keyIsDown(spaceKey)) {
        if (this.currentTime <= 0) {
            shotSound();
            this.bullets.push(new Bullet(this.position, 5, 5));
            this.currentTime = this.timeBetweenShots;
        }
    }

    this.currentTime--;

    for (let i = 0; i < this.bullets.length; i++) {
        this.bullets[i].display();

        if (this.bullets[i].position.y < height / 2 * -1) {
            this.bullets.splice(i, 1);
            continue;
        }

        // Enemies Collision
        for (let j = 0; j < waves[currentWave].enemies.length; j++) {
            let enemy = waves[currentWave].enemies[j];

            if (this.bullets[i].position.y <= enemy.position.y + enemy.bHeight && this.bullets[i].position.y + (this.bullets[i].size / 2) >= enemy.position.y &&
                this.bullets[i].position.x <= enemy.position.x + enemy.bWidth && this.bullets[i].position.x + (this.bullets[i].size / 2) >= enemy.position.x - enemy.bWidth) {
                waves[currentWave].enemies[j].setDamage(this.bullets[i].damage);
                this.bullets.splice(i, 1);
                break;
            }
        }
    }
}

Mesmo se o usuário deixar a barra de espaço pressionada, cada disparo irá demorar um certo período de tempo para ser disparado.

A mesma lógica será utilizada nos disparos do inimigo que irá estabelecer um intervalo a cada disparo e avaliar a detecção de colisões com disparos.

disparos jogo guerra nas estrelas

Definição e elaboração de ondas

Cada onda será responsável por gerenciar o número de inimigos e os padrões de movimentos do inimigos.

class Wave {
    constructor(enemiesCount, level = 1, size = 30, speed = 2, shotSpeed = 2, hp = 100) {
        this.enemiesCount = enemiesCount;
        this.startVerticalCoordinate = height / 4 * -1;
        this.isReady = false;

        let enemies = [];
        for (let i = 0; i < enemiesCount; i++) {
            let enemyPos = createVector(this.getInitialXPos(i, enemiesCount), height / 2 * -1, i * 10);
            enemies.push(
                new Enemy(hp, enemyPos, level, size, size, speed, shotSpeed)
            );
        }

        this.enemies = enemies;
    }
}

No construtor da classe “Wave”, os inimigos serão instanciados e terão suas coordenadas iniciais calculadas.

enemyStrategies() {
    for (let i = 0; i < this.enemies.length; i++) {
        this.enemies[i].display();

        // If Destroyed
        if (this.enemies[i].hp <= 0) {
            this.enemies[i].loseControl();
            if (this.enemies[i].bullets.length <= 0) {
                explosionSoundEffect.play();
                this.enemies.splice(i, 1);
            }
            continue;
        }

        this.enemies[i].position.x += this.enemies[i].speed;

        // Edges
        if (this.enemies[i].position.x > width / 2 - this.enemies[i].bWidth || this.enemies[i].position.x < (width / 2 - this.enemies[i].bWidth) * -1) {
            this.enemies[i].speed *= -1;
        }

        this.enemies[i].fire();
    }
}

A função “enemyStrategies” é bastante simples. Caso um dos inimigos tenha chegado em uma das laterais da tela, eles irão ter sua direção voltada para o lado contrário. Nessa função também serão escritos comandos como disparos e explosão do inimigo após ter sua vida acabada.

Tela de game over e game clear

As duas telas game over e game clear compartilham a mesma função.

function gameOverScreen(finalText = 'Game Over') {
    if (backMusic.isPlaying()) {
        backMusic.stop();
    }

    image(startScreenAsset, width / 2 * -1, height / 2 * -1, width, height);
    textSize(25);
    text('Aperte espaço para reiniciar o jogo', 0, height / 4);

    textSize(30);
    text(finalText, 0, height / 4 * -1);
    
    let resetGame = function() {
        currentWave = 0;
        spaceship = new Spaceship(new Nitro(2));
        waves = getWaves();
    };

    if (keyIsDown(spaceKey)) {
        gameState = 2;
        resetGame();
    }
}

O texto final será passado como parâmetro, assim podendo ser personalizado conforme as nossas necessidades.

O que aprendemos desenvolvendo o jogo “guerra nas estrelas”?

Cálculos de física são constantemente utilizados para simular fenômenos que acontecem na vida real. Nesse jogo, simulamos efeitos de atrito e inércia, tornando a movimentação da nave um pouco mais realística.

No desenvolvimento de jogos, outras variáveis que são constantemente definidas são gravidade e a massa. Porém, atribuir um valor realístico pode para essas variáveis pode tornar o game um pouco sem graça. Por isso, atribuímos valores que tornam o jogo mais divertido.

Testar resultados

O jogo está disponível para teste no link abaixo (esse jogo não funciona corretamente em smartphones):
Testar jogo

O repositório também está disponível no github:
Ver repositório

Postagens mais vistas

Os 5 principais componentes do computador

Os 5 principais componentes do computador são a unidade de controle, unidade aritmética e lógica, memória, dispositivo de entrada e dispositivo de saída.

Portas TCP e UDP

A porta é um número de 16 bits que é adicionado no final do endereço IP, insinuando qual aplicativo está vinculado e atuando nessa porta.

LAN

Rede local de computadores (LAN) é um conjunto de computadores ou dispositivos conectados uns aos outros de forma isolada em um pequeno local.

Retornar aos projetos