- Introdução do projeto de guerra nas estrelas
- Ferramentas utilizadas
- Preparando o diretório
- Criando a lógica do jogo
- Estabelecendo os estados do jogo
- Implementação da tela inicial
- Implementação da tela principal (gameplay)
- Tela de game over e game clear
- O que aprendemos desenvolvendo o jogo “guerra nas estrelas”?
- Testar resultados
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:
- Tela inicial
- Tela de jogo
- Tela de game over
- 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.

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();
}
}

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.

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