#gamedev

Criando Uma Janela de Jogo no Windows

Nas próximas linhas eu vou mostrar como criar uma janela de jogo e configurar o loop principal dele.

Todos esses passos exigem seguir a documentação. Eu vou mostrar o código e explicar apenas os pontos principais, assumindo que você irá consultar a documentação em paralelo (como um bom programador o/).

A documentação do Windows se chama MSDN.

Registrar uma classe

O primeiro passo é criar uma WindowClass, uma struct chamada WNDCLASS.

Essa struct precisa de uma função de callback para lidar com tarefas relacionadas à janela, incluindo eventos de teclado.

Precisamos registrar essa struct para criar uma janela corretamente.

Vamos ver o código:

int CALLBACK
WinMain(HINSTANCE instance,
        HINSTANCE prev_instance,
        LPSTR cmd_line,
        int show_cmd)
{
        WNDCLASSA window_class = {};
        window_class.style         = CS_VREDRAW|CS_HREDRAW|CS_OWNDC;
        window_class.lpfnWndProc   = window_callback;
        window_class.hInstance     = instance;
        window_class.lpszClassName = "GameClassName";
        // window_class.hIcon   -> load icon
        window_class.hCursor       = LoadCursor(NULL, IDC_ARROW); 

        if (!RegisterClassA(&window_class)) {
            printf("Failed to Register Class A\n");
            return 1;
        }
}

Os pontos principais são:

  • style: informações em bits que determinam como a janela opera;
  • lpfnWndProc: uma função, que vamos criar depois, para lidar com eventos da janela como redimensionar, criar, destruir, etc;
  • hInstance: handle da instância da aplicação;
  • lpszClassName: nome da classe da janela;
  • CS_VREDRAW: redesenha a janela verticalmente, se necessário, quando o tamanho muda;
  • CS_HREDRAW: redesenha a janela horizontalmente, se necessário, quando o tamanho muda;
  • CS_OWNDC: aloca apenas um DeviceContext para a janela. O Windows usa o DC para manter o estado do desenho enquanto desenhamos na janela. Inicialmente, ele foi pensado para ser usado junto com brushes. Normalmente, o Windows teria vários device contexts disponíveis. Quando um programa precisa desenhar, ele pega um, usa e depois devolve;
  • RegisterClassA: função necessária para a janela funcionar corretamente.

Também podemos obter o handle da instância chamando GetModuleHandle(NULL).

A função OutputDebugStringA serve como saída padrão para o modo de debug, visível apenas dentro do Visual Studio. Se você precisar ver a saída no console, troque pela função printf do arquivo stdio.h, que faz parte da biblioteca padrão de C.

Entendendo a flag CS_OWNDC

No começo do código, definimos o estilo da classe da janela assim:

window_class.style = CS_VREDRAW|CS_HREDRAW|CS_OWNDC;

Esses valores são flags. Uma flag é como uma opção de configuração que podemos ligar ou desligar.

O operador | combina várias flags em um único valor. Então essa linha está dizendo: "quero uma janela com CS_VREDRAW, CS_HREDRAW e CS_OWNDC ligados ao mesmo tempo".

CS_OWNDC faz com que cada janela criada a partir dessa classe tenha seu próprio Device Context.

O Device Context, ou DC, é uma estrutura que o Windows usa para guardar o estado de desenho: onde desenhar, quais configurações estão sendo usadas, qual superfície está recebendo o desenho, entre outras informações.

Sem CS_OWNDC, o Windows pode emprestar um DC temporário para o programa quando ele precisa desenhar e depois pegar esse DC de volta. Isso é suficiente para muitos programas comuns.

Com CS_OWNDC, a janela fica com um DC próprio. Para jogos, isso costuma ser útil porque desenhamos com muita frequência e queremos uma relação mais direta e estável com a superfície da janela.

Neste ponto do código, ainda estamos apenas pintando a janela com preto. Mas essa configuração prepara o terreno para os próximos passos, como criar um back buffer e atualizar a tela do jogo continuamente.

Criar uma janela

Vamos criar o handle da janela antes de definir a função de callback.

HWND window_handle = CreateWindowA(
    window_class.lpszClassName,
        "Game!",
        WS_OVERLAPPEDWINDOW|WS_VISIBLE,
        CW_USEDEFAULT,
        CW_USEDEFAULT,
        960,
        540,
        NULL,
        NULL,
        instance,
        NULL);

if (!window_handle) {
    printf("Failed to Create Window A\n");
    return 1;
}

Os três parâmetros principais são o nome da classe windowClass.lpszClassName, o título da janela e a própria instância.

CW_USEDEFAULT é usado para substituir as coordenadas (x, y) por um valor padrão baseado na tela do usuário.

Veja a documentação para entender quais são os outros parâmetros e como usá-los, se precisar.

Procedimento da janela

Agora vamos ver como implementar a função de callback.

LRESULT CALLBACK
main_window_callback(HWND hwnd,
                     UINT message,
                     WPARAM lparam,
                     LPARAM wparam)
{
    LRESULT result = 0;
    switch (message) {
        case WM_CREATE: {
            OutputDebugStringA("Window Created.\n");
        } break;

        case WM_PAINT: {
            PAINTSTRUCT paint;
            HDC device_handle = BeginPaint(hwnd, &paint);

            LONG width  = paint.rcPaint.right - paint.rcPaint.left;
            LONG height = paint.rcPaint.bottom - paint.rcPaint.top;

            PatBlt(device_handle, 0, 0, width, height, BLACKNESS);

            EndPaint(hwnd, &paint);
        } break;

        case WM_CLOSE: {
            DestroyWindow(window);
        } break;

        case WM_DESTROY: {
            PostQuitMessage(0);
        } break;

        default: {
            result = DefWindowProcA(hwnd, message, lparam, wparam);
        }
    }
    return result;
}

O callback devolve parâmetros meio genéricos. Quero dizer: para cada situação da janela, conseguimos obter valores específicos com base nos eventos.

Esses valores ficam dentro de WPARAM e LPARAM.

No futuro vamos utilizar esses parâmetros para processar eventos de mensagens do teclado.

O último parâmetro da função CreateWindow é um ponteiro void onde podemos definir valores e recuperá-los de LPARAM durante o estado WM_CREATE.

Entendendo o bloco WM_PAINT

O bloco WM_PAINT é chamado quando o Windows entende que alguma parte da janela precisa ser desenhada novamente.

Isso pode acontecer quando a janela aparece pela primeira vez, quando ela é redimensionada, quando outra janela sai da frente dela ou quando o próprio programa pede para uma área ser redesenhada.

Pense na janela como uma folha. Se uma parte dessa folha fica "suja" ou desatualizada, o Windows marca aquela região como uma área que precisa de pintura. Depois ele envia a mensagem WM_PAINT para o programa.

No código, essa pintura começa com:

PAINTSTRUCT paint;
HDC device_handle = BeginPaint(hwnd, &paint);

PAINTSTRUCT é uma struct preenchida pelo Windows com informações sobre a pintura atual. A parte mais importante aqui é paint.rcPaint, que informa o retângulo que precisa ser redesenhado.

BeginPaint avisa ao Windows: "eu vou começar a pintar a janela agora". Ela também retorna um HDC, que é o Device Context usado para desenhar.

De forma simples: o HDC é como se fosse a "caneta" ou a "superfície de desenho" que o Windows entrega para o programa. É por meio dele que chamamos funções de desenho.

Em seguida calculamos a largura e altura da área que precisa ser pintada:

LONG width  = paint.rcPaint.right - paint.rcPaint.left;
LONG height = paint.rcPaint.bottom - paint.rcPaint.top;

O rcPaint possui os limites da área inválida: esquerda, topo, direita e base. Subtraindo esses valores, descobrimos o tamanho da região que o Windows pediu para redesenhar.

Depois usamos:

PatBlt(device_handle, 0, 0, width, height, BLACKNESS);

PatBlt preenche uma área retangular usando uma operação de raster. Com BLACKNESS, estamos dizendo para o Windows preencher essa área com preto.

Ou seja, nesse exemplo, quando a janela precisa ser redesenhada, nós simplesmente pintamos a área necessária de preto.

Por fim:

EndPaint(hwnd, &paint);

EndPaint avisa ao Windows que terminamos de pintar. Isso é importante porque o Windows limpa a marcação de "área inválida" da janela. Se chamarmos BeginPaint, precisamos chamar EndPaint no final.

Para um jogo, esse desenho dentro do WM_PAINT costuma ser apenas um primeiro passo. Mais adiante, normalmente vamos desenhar em um back buffer e copiar o resultado para a janela. Mas, por enquanto, pintar a janela de preto é suficiente para testar se o processo de pintura está funcionando.

Processar mensagens para o game loop

O próximo passo é traduzir e despachar mensagens da fila de mensagens.

Essas mensagens incluem mensagens da janela, entrada do teclado, entrada do mouse e outras.

Vamos usar PeekMessage para recuperar mensagens da fila.

Claro, precisamos executar todo esse processo dentro de um loop principal controlado por uma variável booleana. Caso contrário, a janela vai piscar e fechar instantaneamente.

Eu declaro essa variável booleana com o nome should_quit.

Palavra-chave static e processamento da fila de mensagens

A variável está no escopo global e é declarada com a palavra-chave static.

O significado de static varia dependendo do contexto em que aparece.

Por exemplo, se static é usado em uma variável de escopo local, ela se comporta como uma variável local persistente.

Quando usado em uma função, restringe a função ao arquivo da unidade de tradução. Em outras palavras, ela se torna uma função internal.

Vamos olhar o código:

No topo do arquivo, temos uma variável global chamada should_quit.

static bool should_quit;

Agora abaixo do arquivo:

static void process_message_queue() {
    MSG msg;
    while(PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE)) {
        if (msg.message == WM_QUIT) {
            should_quit = true;
        } 
        TranslateMessage(&msg);
        DispatchMessageA(&msg);
    }
}

Dentro da função principal, temos:

// ...
while(!should_quit) {
    process_message_queue();
}

OutputDebugStringA("end program!\n");

return 0;

Lendo o código, podemos observar que a função PeekMessageA preenche a struct Message para nós e, se essa struct não for zero, processamos a mensagem.

Quando o usuário clica no botão de fechar, o callback da janela lida com a mensagem WM_CLOSE. Dentro desse bloco, chamamos DestroyWindow, que por sua vez dispara WM_DESTROY para forçar um PostQuitMessage.

Como resultado, a próxima mensagem é uma mensagem WM_QUIT, que quebra o loop e encerra o jogo.

O passo final é adicionar a biblioteca estática gdi32.lib ao arquivo build.bat.

Conclusão

Para criar uma janela, precisamos determinar uma classe de janela que contém título e ícones para a janela. Ela também inclui o procedimento de callback da janela para lidar com a etapa de pintura, criação e fechamento da janela.

Depois de definir a classe da janela, precisamos registrar essa classe para usar a janela.

Agora, criamos uma janela com coordenadas, título e algumas flags para definir propriedades como bordas, visibilidade etc.

Criamos um loop principal para processar a fila de mensagens da thread atual e a fila de mensagens da janela.

Esse processo permite traduzir a tecla virtual para uma mensagem de caractere e despachar essa mensagem para o procedimento de callback da janela.

Como você pode ver abaixo, o segundo parâmetro de PeekMessageA deve ser nulo para que mensagens da janela e mensagens da thread sejam processadas (WM_QUIT é uma mensagem da thread). Caso contrário, não conseguimos fechar o programa, mesmo depois da janela ser fechada.

O próximo passo é alocar um back buffer.

Meu acesso rápido

  • Criar WNDCLASSA
  • Registrar uma classe
  • Criar o procedimento de callback da janela
  • Criar Window
  • Criar GameLoop
  • Processar a fila de eventos com Peek Message
  • Extra: pintar a janela inteira com PatBlit, BeginPaint e EndPaint

Referências

  • WNDCLASSA - contém os atributos da classe da janela registrados pela função RegisterClass
  • DefWindowProcA - chama o procedimento padrão da janela para fornecer o processamento padrão de qualquer mensagem que a aplicação não processa.
  • RegisterClassA - registra uma classe de janela para uso posterior em chamadas para CreateWindow
  • CreateWindowA - cria uma janela sobreposta, pop-up ou filha
  • PeekMessageA - despacha mensagens recebidas que não estão na fila, verifica a fila de mensagens da thread em busca de uma mensagem postada e recupera a mensagem, se existir. Usada para operações longas.
    • IMPORTANTE: hwnd deve ser NULL para que mensagens da janela e mensagens da thread sejam processadas.
  • DispatchMessageA - despacha uma mensagem para um procedimento de callback da janela.
  • TranslateMessage - traduz a tecla virtual para mensagens de caractere. As mensagens de caractere são postadas na fila de mensagens da thread chamadora, para serem lidas na próxima vez que a thread chamar PeekMessage.
  • DestroyWindow - envia uma mensagem para a janela para desativá-la e remover o foco do teclado.
  • PostQuitMessage - indica ao sistema que uma thread fez uma solicitação para encerrar.