Criando Uma Janela de Jogo no Windows
Table of Contents
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 deLPARAMdurante o estadoWM_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.