Gabriel Rocha
Projetos

AstroBeat

Jogo rítmico 3D de corrida infinita, desenvolvido em C com a biblioteca raylib.

2026Repositório

AstroBeat é um jogo rítmico 3D no estilo corrida infinita, escrito em C com a raylib. O jogador pilota uma nave por uma pista neon dividida em três faixas, desviando de obstáculos e acertando notas no ritmo da música. Cada música tem um beatmap próprio, então a jogabilidade é sincronizada com o áudio em vez de ser gerada proceduralmente.

Estrutura e máquina de estados

O jogo é dividido em módulos por responsabilidade (main.c, menu.c, notas.c, score.c, player.c, environment.c, buffs.c, audio.c, highscores.c), e o main roda uma máquina de estados declarada em globals.h:

typedef enum {
    MENU, SONG_SELECT, SETTINGS, HIGHSCORES,
    PLAYING, PAUSED, RESULTS, QUIT
} GameState;

O laço principal é um while (true) com um switch grande que atualiza e depois desenha o estado atual. Duas câmeras 3D coexistem: uma para o cenário de fundo (menu) e outra para a jogabilidade.

A sincronia com a música

O coração do jogo é o relógio da música. Enquanto joga, o tempo do jogo é a posição tocada pela raylib descontada do instante em que a música começou:

if (gameState == PLAYING || gameState == PAUSED) {
    tempo_jogo = obterTempoMusica(&audio) - tempo_inicio_musica;
    if (tempo_jogo < 0.0f) tempo_jogo = 0.0f;
}

As notas e obstáculos não usam física por frame: a posição 3D é uma função do tempo da música. Uma nota nasce em z = -80 e viaja a VEL_NOTAS = 10.0 unidades por segundo; o instante do acerto é quando tempo_jogo cruza o tempo da nota (a linha de acerto fica em z ≈ -2, o HIT_OFFSET):

float z = -80.0f + (tempo_atual - (array_notas[i].tempo - TEMPO_ATE_HIT)) * VEL_NOTAS;

Como a posição deriva do tempo, o desenho fica desacoplado da taxa de frames (o alvo é 144 FPS), mas todo o julgamento passa a depender do relógio do áudio.

Mapas e notas

Cada música em songs/ tem um beatmap em mapasMusicas/ e uma lista de coletáveis em mapasColetaveis/. A leitura usa um formato simples de texto, tempo faixa tipo duracao por linha:

while (i < 1000 && fscanf(f, "%f %d %d %f",
        &array_notas[i].tempo,
        &array_notas[i].lane,
        (int*)&array_notas[i].tipo,
        &array_notas[i].duracao) == 4) {
    array_notas[i].ativa = 0;
    array_notas[i].resultado = JULG_PENDENTE;
    i++;
}
total_de_notas = i;

Cada nota tem um tipo (grave, agudo, longa, esquerda, direita) e uma faixa (0 a 2). Para acertar uma nota normal é preciso estar na mesma faixa dela (laneAnterior == lane) e pressionar a tecla correspondente ao tipo no instante certo; a nota longa é segurada com espaço.

Julgamento e pontuação

O julgamento usa janelas de tempo em torno do instante ideal da nota, definidas como constantes:

float JANELA_PERFECT = 0.050f;
float JANELA_GREAT   = 0.075f;
float JANELA_GOOD    = 0.100f;
float JANELA_OK      = 0.125f;
float JANELA_MISS    = 0.150f;

O hit acontece quando a diferença |tempo_atual - nota.tempo| cai na janela certa, e cada julgamento tem um multiplicador próprio somado ao multiplicador de streak:

float multiplicador_julgamento(Julgamento nota) {
    case JULG_OK:      return 1.0f;
    case JULG_GOOD:    return 1.25f;
    case JULG_GREAT:   return 1.5f;
    case JULG_PERFECT: return 2.0f;
}

float multiplicador_streak(int streak) {
    if (streak >= 30) return 8.0f;
    if (streak >= 20) return 4.0f;
    if (streak >= 10) return 2.0f;
    return 1.0f;
}

Errar zera o streak e volta o multiplicador para 1. As notas longas são julgadas em duas partes: a cabeça segue a janela normal e, enquanto a tecla é segurada, a cada 0.1s soma 10 pontos por multiplicador; soltar antes do fim conta como erro. Ao final, os acertos são organizados numa matriz por tipo (grave, agudo, longa, movimento e geral) com totais, acertos, erros e precisão em %, e o rank (S, A, B, C, D ou F) sai da precisão geral.

Os buffs (coletáveis nas pistas) mexem nessa lógica: o multiplicador 2x dobra os pontos por um tempo e a “janela +20ms” alarga as janelas de julgamento em 20 ms.

Áudio, configuração e recordes

O audio.c carrega a música com a raylib, controla play/pause/seek e persiste o volume em configuracoes.cfg (texto, com padrão 0.8). O highscores.c guarda, por música, a maior pontuação em highscores.cfg no formato Nome=pts e só atualiza o arquivo quando a nova pontuação supera a anterior. O DataLoader de outros projetos não existe aqui; o jogo cria os arquivos de configuração em tempo de execução.

Particularidades e pontos que revisaria

  1. Debug espalhado no código final. O jogo imprime julgamento a cada nota com printf (PERFECT, GREAT, MISS, LONG COMPLETA, PAUSADO em: %.4f e vários fflush), e ainda existe um atalho de debug que pula a música 10 segundos (KEY_F, comentado como “DEBUG: skip na música”). Isso não deveria estar no build de entrega.

  2. Estado global pesado e limites fixos. gameState é global, e array_notas[1000] com total_de_notas são extern compartilhados entre módulos. A leitura do beatmap para em 1000 notas sem avisar, então uma música com mapa maior seria truncada em silêncio. Funciona num jogo acadêmico, mas o acoplamento dificulta testar e evoluir.

  3. Cast de enum e falta de validação no mapa. Para ler o tipo com %d eu faço (int*)&array_notas[i].tipo, o que depende do enum ser int, e não valido faixa, tipo nem a ordem dos tempos na leitura. Uma linha com lane ou tipo inválido só se manifesta mais tarde (o desenho ignora faixas fora de 0 a 2).

  4. Geometria derivada do tempo (decisão que eu manteria). A posição de cada nota é uma projeção do tempo da música, o que deixa o visual estável independente de frame. O preço é que o julgamento inteiro depende do GetMusicTimePlayed da raylib; se o áudio dessincronizar, viram erros de julgamento.

  5. Configuração em texto na raiz. configuracoes.cfg e highscores.cfg são escritos no diretório de trabalho atual; o parse de recordes usa %99[^=]=%d e compara o nome com a lista de músicas, o que é frágil se o arquivo for editado na mão.

Pontos de melhoria