Animando o Console - Efeito Matrix Parte II

Saída da 2ª animação em modo texto. Bem mais interessante.
No último post criamos um pequeno programa que imprimia zeros e uns em posições aleatórias da tela, criando um efeito muito simples, mas nada parecido com o efeito do console do matrix que estamos querendo escrever.

O objetivo deste primeiro programa era se familiarizar com as funções da API do windows e começar a conhecer o ambiente e suas limitações.

O leitor mais atento poderá ter percebido, entretanto, vários problemas com o programa escrito. O primeiro deles é que não estamos realmente gerando os quadros (frames) de uma animação, estamos simplesmente jogando caracteres na tela da maneira mais displiscente e ineficiente se pode imaginar.

Outro problema sério é que não temos nenhum mecanismo de buffer, estamos escrevendo direto na área visível da aplicação e, como vimos na série Animações em Tempo Real com a VCL, não dá pra ir muito longe assim (note que o ambiente é diferente, mas as técnicas que utilizaremos são exatamente as mesmas).

Vamos começar a pôr ordem na casa então! Mas antes, se você estiver curioso, baixe o programa compilado e veja o resultado do código que vamos construir.

Bem mais interessante, heim!

Preparando o ambiente


Primeiro, vamos definir os handlers que irão apontar para nossos buffers. Precisamos de dois buffers, um que aponte pra uma área invisível na memória e um que aponte para o buffer de saída do console. Declare as seguintes variáveis e escreva uma nova procedure para realizarmos as inicializações.

var
  hBuffer1, hBuffer2, hBackBuffer   : THandle;
  consoleBounds : TCOORD;   

(...)

procedure ConfigConsole;
var
  lCursorInfo    : TConsoleCursorInfo;
  lSecAttributes : TSecurityAttributes;
begin
  SetConsoleTitle('DelphiGames Blog | Matrix Effect - Part 2');
  Write('Getting handlers... ');

  lSecAttributes.nLength:= sizeOf(TSecurityAttributes);
  lSecAttributes.lpSecurityDescriptor := nil;
  lSecAttributes.bInheritHandle := false;

  hBuffer1 := GetStdHandle(STD_OUTPUT_HANDLE);
  hBuffer2 := CreateConsoleScreenBuffer( GENERIC_WRITE or GENERIC_READ,
                                         FILE_SHARE_READ,
                                         lSecAttributes,
                                         CONSOLE_TEXTMODE_BUFFER, nil);

  if (hBuffer1 = INVALID_HANDLE_VALUE) or (hBuffer2 = INVALID_HANDLE_VALUE) then
  begin
     WriteLn('ERROR');
     Halt(1);
  end
  else
     begin
       WriteLn('OK!');

       hBackBuffer := hBuffer1;
       lcursorInfo.dwSize   := 1;
       lcursorInfo.bVisible := False;

       SetConsoleCursorInfo(hBuffer1, lcursorInfo);
       SetConsoleCursorInfo(hBuffer2, lcursorInfo);

       consoleBounds.X:= 80;
       consoleBounds.Y:= 25;

       SetConsoleScreenBufferSize(hBuffer1, consoleBounds);
       SetConsoleScreenBufferSize(hBuffer2, consoleBounds);
     end;
end;

Ôpa! Tem coisa nova aí, mas não precisar se intimidar não.

Começamos setando o título da janela, apontamos o hBuffer1 para o buffer de saída do console e criamos um novo buffer usando a API CreateConsoleScreenBuffer armazenando seu handler em hBuffer2. Seguimos checando se os dois buffers foram inicializados corretamente testando seu valor contra a constante INVALID_HANDLE_VALUE e, se estiver tudo ok, ajustamos o hBackBuffer para apontar para hBuffer1,  escondemos o cursor (SetConsoleCursorInfo) e concluímos ajustando o tamanho dos buffes para 80x25 caracteres (SetConsoleScreenBufferSize).

Ok. Temos um procedimento pra ajustar o console e alguns ponteiros (o handler acaba sendo um tipo de ponteiro no final das contas) para os buffers. A idéia é realizar todos as saídas em  hBackBuffer e, quando tivermos um frame pronto, fazemos um swap dos ponteiros, exibindo o buffer em estávamos trabalhando e escondendo o atual. Esta técnica também é conhecida como page-flipping e vamos implementá-la agora com a ajuda de mais uma chamda à Win32 API (SetConsoleActiveScreenBuffer). Escreva o método SwapBuffers logo abaixo da implementação de  ConfigConsole.

procedure SwapBuffers;
begin
  if hBackBuffer = hBuffer1 then
  begin
    SetConsoleActiveScreenBuffer(hBuffer1);
    hBackBuffer:= hBuffer2;
  end
  else begin
    SetConsoleActiveScreenBuffer(hBuffer2);
    hBackBuffer:= hBuffer1;
  end;
end;  


Para concluir o setup do ambiente, implemente a função clear logo depois de SwapBuffers e e altere código de entrada do programa conforme exibido a seguir

procedure Clear;
var
  tc :tcoord;
  nw: DWORD;
  cbi : TConsoleScreenBufferInfo;
begin
  GetConsoleScreenBufferInfo(hBackBuffer, cbi);
  tc.x := 0;
  tc.y := 0;
  FillConsoleOutputAttribute(hBackBuffer, Black,cbi.dwsize.x*cbi.dwsize.y, tc, nw);
  FillConsoleOutputCharacter(hBackBuffer, ' ', cbi.dwsize.x*cbi.dwsize.y, tc, nw);
  SetConsoleCursorPosition(hBackBuffer, tc);
end; 

//ponto de entrada do programa
begin
  randomize;
  ConfigConsole;
  while hi(GetKeyState(VK_ESCAPE)) = 0 do
  begin
    Clear;
    SwapBuffers;
  end;
end.  

Pronto! Com estas alterações seu ambiente está preparado.

Compile e execute o programa e você vai uma incrível... tela preta! Exatamente igual à imagem ao lado.

Mas não se engane, esta tela, agora dotada de um buffer duplo e uma lógica de page flipping, é capaz de muito mais do que pode indicar sua aparência simplória.

E é isto que vamos começar a descorbrir na próxima seção deste texto.

De volta à Matrix

Podemos pensar no efeito como sequências de caracteres que descem na tela deixando um rastro que vai ficando mais fraco até sumir. Esses caracteres começam em uma posição aleatória no eixo x e com y = 0. Vamos chamar cada um desses rastros de Strip.

No espaço entre a cláusula uses e a declaração das variáveis globais, faça as alterações necessárias para que seu código fique igual o código a seguir:

const
  Black          = 0;
  Green          = 2;
  LightGreen     = 10;
  White          = 15;

  STRIP_COUNT    = 150;     //quantos strips teremos em nosso array
  STRIP_MAX_LEN  = 25;      //tamanho máximo do rastro deixado pelo strip
  STRIP_MIN_LEN  = 6;       //tamanho mínimo deixado pelo rastro.

type
  TStrip = record
    Position : COORD;       //posição de nosso strip na tela
    Length   : byte;        //tamanho do rastro
    Delay    : integer;     //tempo de espera até o strip começar a ser exibido
  end;

var
  hBuffer1, hBuffer2, hBackBuffer   : THandle;
  consoleBounds : TCOORD;
  strips: array[0..STRIP_COUNT] of TStrip;  

Tudo bem simples.

Declaramos as constantes de cor (lembra que temos uma paleta de 16 cores dispoíveis?) que nos interessam, algumas constantes de parametrização dos strips, um record TStrip com a estrutura de dados necessária e, no final, um array de TStrip para armazenar os dados.

Agora vamos iniciar os strips com valores  aleatórios.

procedure InitStrips;
var
  i: integer;
begin
  for i:=0 to STRIP_COUNT-1 do
  begin
    strips[i].Length      := random(STRIP_MAX_LEN - STRIP_MIN_LEN) + STRIP_MIN_LEN;
    strips[i].Position.y  := 0;
    strips[i].Position.x  := random(consoleBounds.x);
    strips[i].Delay       := random(20);
  end;
end; 

E atualizá-los a cada iteração de nosso loop principal com a seguinte rotina.

procedure UpdateStrips;
var
  i : integer;
begin
  for i:=0 to STRIP_COUNT-1 do
  begin
    if strips[i].Delay > 0 then
       strips[i].Delay := strips[i].Delay -1
    else
       begin
         strips[i].Position.Y := strips[i].Position.Y + 1;
         if ( strips[i].Position.Y - strips[i].Length  > consoleBounds.Y ) then
         begin
            strips[i].Length     := random(STRIP_MAX_LEN - STRIP_MIN_LEN) + STRIP_MIN_LEN;
            strips[i].Position.y := 0;
            strips[i].Position.x := random(consoleBounds.x);
            strips[i].Delay      := random(100);
          end;
       end;
  end;
end;

Só restam mais dois passos. Desenhar os strips e ajustar o loop principal para incluir as rotinas criadas.

procedure DrawStrips;
var
  i, j : integer;
  lColor, lCharsWritten: DWORD;
  lChar  : char;
  lPosition: COORD;
begin
  lCharsWritten := 0;
  for i:=0 to STRIP_COUNT-1 do
    begin
      if (strips[i].Delay <= 0) then
      begin
        if (strips[i].Position.Y <= consoleBounds.Y) then
        begin
          //desenhamos o primeiro caractere em branco
          lColor:= White;
          lChar := Char(random(255-33)+33);
          WriteConsoleOutputAttribute(hBackBuffer, @lColor, 1, strips[i].Position, lCharsWritten);
          WriteConsoleOutputCharacter(hBackBuffer, @lChar, 1, strips[i].Position, lCharsWritten);
        end;
        for j:=1 to strips[i].Length-1 do
          if (strips[i].Position.Y + j <= consoleBounds.Y) then
          begin
             //os primeiros 35% do rastro serão desenhados em verde claro
             if (j / strips[i].Length <= 0.35) then
                lColor:= LightGreen
             else
                lColor:= Green;
             lChar := Char(random(255-33)+33);
             lPosition.X:= strips[i].Position.X;
             lPosition.Y:= strips[i].Position.Y - j;
             WriteConsoleOutputAttribute(hBackBuffer, @lColor, 1, lPosition, lCharsWritten);
             WriteConsoleOutputCharacter(hBackBuffer, @lChar, 1, lPosition, lCharsWritten);
          end;
      end;
    end;
end;

begin
  randomize;
  ConfigConsole;
  InitStrips;
  while hi(GetKeyState(VK_ESCAPE)) = 0 do
  begin
    Clear;           //limpamos a tela
    UpdateStrips;    //calculamos a posição dos strips no quadro atual
    DrawStrips;      //desenhamos todos os strips visíveis
    SwapBuffers;     //exibimos o quado criado com um page-flip
    Sleep(10);       //aguardamos 10ms para 
  end;
end.  

Compile o código e você verá o efeito fucionando. Bacana heim!

Deferenças de Ambiente

Fizemos tudo certo até aqui, mas o código não compila no Lazarus... porquê?

Porque há uma diferença na declaração da função CreateConsoleScreenBuffer no arquivos windows.pas que acompanha o Delphi e o windows.pas que acompanha o Lazarus. Enquanto um espera uma referência para uma estrutura TSecurityAttributes o outro espera um ponteiro não tipado. Nada complicado, se usarmos a diretiva de compilação condicional certa para resolver a questão.

Quando usamos o compilador do free pascal, como é o caso do Lazarus, a diretiva {$DEFINE FPC} sempre estará ligada, nos dando um teste seguro para isolar as eventuais diferenças entre os dois mundos portanto, vamos alterar a função ConfigConsole para podermos compatibilizar nosso código com esses dois compiladores.

procedure ConfigConsole;
var
  lCursorInfo    : TConsoleCursorInfo;
  lSecAttributes : TSecurityAttributes;
begin
  SetConsoleTitle('DelphiGames Blog | Matrix Effect - Part 2');
  Write('Getting handlers... ');

  lSecAttributes.nLength:= sizeOf(TSecurityAttributes);
  lSecAttributes.lpSecurityDescriptor := nil;
  lSecAttributes.bInheritHandle := false;

  hBuffer1 := GetStdHandle(STD_OUTPUT_HANDLE);
  {$IFDEF FPC}
  hBuffer2 := CreateConsoleScreenBuffer( GENERIC_WRITE or GENERIC_READ,
                                         FILE_SHARE_READ,
                                         lSecAttributes,
                                         CONSOLE_TEXTMODE_BUFFER, nil);

  {$ELSE}
  hBuffer2 := CreateConsoleScreenBuffer( GENERIC_WRITE or GENERIC_READ,
                                         FILE_SHARE_READ,
                                         @lSecAttributes,
                                         CONSOLE_TEXTMODE_BUFFER, nil);
  {$ENDIF}
  if (hBuffer1 = INVALID_HANDLE_VALUE) or (hBuffer2 = INVALID_HANDLE_VALUE) then
  begin
     WriteLn('ERROR');
     Halt(1);
  end
  else
     begin
       WriteLn('OK!');

       hBackBuffer := hBuffer1;
       lcursorInfo.dwSize   := 1;
       lcursorInfo.bVisible := False;

       SetConsoleCursorInfo(hBuffer1, lcursorInfo);
       SetConsoleCursorInfo(hBuffer2, lcursorInfo);

       consoleBounds.X:= 80;
       consoleBounds.Y:= 25;

       SetConsoleScreenBufferSize(hBuffer1, consoleBounds);
       SetConsoleScreenBufferSize(hBuffer2, consoleBounds);
     end;
end;

Notas Finais

Apesar do resultado do código que construímos neste post ter bem mais interessante que o código anterior, ainda há (sempre há) espaço para várias melhorias. Vou enumerar algumas aqui e deixá-las como proposta de exercícios para o leitor.

Sugestões de melhoria:

  • A velocidade da animação está dependente da velocidade do processador em que está sendo executada. Com poucas modificações no código, é possível adquirir uma taxa de frames fixa. Será que você consegue implementá-la?.
  • Todos os strips descem com a mesma velocidade. Como ficaria o feito se eles caíssem com velocidades diferentes?
  • Que tal exibir a taxa de quadros por segundo em que o programa está operando na barra de títulos do terminal?
  • Há um "slowdown" no início da animação. O que está causando isto? Você consegue resolver?


É isso aí pessoal. Espero que tenham gostado e até o próximo texto.

Abraços.

Links