Padrões de Projetos para Jogos - Máquina de Estado

Primeiramente, gostaria de me desculpar pelo atraso, nós estavamos com sérios problemas com o sistema do WordPress pra publicar esse post(que era pra ter saído a umas 2 semanas!) Mas uma simples tag que tinha nele impedia a gente de postar, mas agora ajeitamos e aqui está mais um tutorial escrito por um convidado, Bruno Schifer, que mandou diretamente do fórum e está sendo publicado aqui.

Só lembrando que quem quiser participar também é só postar aqui.

E fiquem com o tutorial:

Nesse tutorial eu vou ensinar uma técnica de programação muito útil para o desenvolvimento de jogos.

Trata-se da máquina de estado que pode ser usada para controle da execução do seu jogo entre outras coisas.

Antes de mais nada, vamos chamar o objeto de estudo deste tutorial corretamente, pois o nome está incompleto.

As máquinas de estado são mais corretamente denominadas Máquina de Estado Finito ou em inglês “Finite State Machine” ou ainda melhor, FSM.

A utilização das FSMs no mundo da programação já é muito difundida e qualquer projeto mais complexo pode se utilizar desta técnica para resolver problemas comuns de lógica.

Com a utilização, foi-se criando um padrão de implementação para esse objeto e não é à toa que os autores Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides, incluíram um padrão que pode ser usado para implementar a FSM em C++ no livro “Design Patterns: Elements of Reusable Object-Oriented Software”.

Eu já encontrei FSMs em implementações de jogos, algoritmos de inteligência artificial, algoritmos de parsing (compiladores) e em muitos outros lugares, ou seja, é muito importante para qualquer desenvolvedor de jogos entender o funcionamento das Máquinas de Estado Finito.

O estudo das FSMs é normalmente conhecido como a Teoria dos Autômatos ou ainda Teoria da Computação e ela é ensinada nas universidades para a implementação de compiladores e algoritmos de parsing, apesar de ter muitas outras aplicações.

Vamos então ao significado do termo: FSM é um modelo de comportamento composto por um número finito de estados, transições e eventos que geram essas transições.

Um modelo é uma interpretação ou abstração de uma realidade para que possamos compreender e utilizar o objeto com maior facilidade.

Portanto, a máquina de estados cria um modelo do comportamento de um objeto para que possamos utilizá-lo em nossas aplicações.

Por número finito de estados queremos dizer que nós podemos contar quantos estados a máquina possui.

Logo, esse modelo de comportamento pode assumir um número limitado de estados.

Com base nisso, nós podemos representar uma FSM através de um “diagrama de transição de estados” ou através de um “diagrama de estados” definido na UML.

Não vou me aprofundar nos diagramas citados acima, pois eles não entram no escopo deste tutorial.

O que eu vim aqui mostrar é uma implementação de uma FSM utilizando o State Pattern definido no livro de Padrões de Projetos do Erich Gamma.

Vamos ao exemplo…

Simplificando o modelo, vamos imaginar um jogo qualquer bem simples. A minha idéia é criar um jogo que possua uma tela de apresentação, uma tela de menu e uma tela com o jogo em si. Dessa forma, concluímos que o número de estados dessa aplicação é 3 (três).

As transições entre os estados são acionadas a partir de certos eventos. Vamos tentar relacioná-los aqui:

Quando o jogo entra na tela de apresentação não é possível que o jogador saia da tela. Ele terá que aguardar 5 segundos para que ocorra a primeira transição de estado. Após os 5 segundos, um evento ocorrerá e a transição para o estado de menu será acionada. Portanto, o primeiro evento de transição é o tempo alcançar 5 segundos após a entrada na máquina.

Dentro do estado de menu, o jogador pode sair da aplicação pressionando o botão saída ou pode entrar no estado jogo, pressionando o botão jogar. As transições nesse estado ocorrem somente com o pressionamento de dois botões.

Por último, o jogador estando dentro da tela de jogo, pode sair da mesma pressionando o botão ESC no teclado. Ao sair desse estado, a máquina volta para o estado menu.

Vamos visualizar em um diagrama de estados da UML o modelo de comportamento desse jogo:

Com esse diagrama, conseguimos ver exatamento o comportamento do jogo mapeado em um modelo de FSM.

Após ter definido nosso modelo, podemos começar a implementar.

Como meu objetivo é mostrar a implementação do State Pattern, não vou apresentar uma aplicação gráfica aqui. Tudo será implementado no console do DOS.

Ao código então…

Crie um projeto vazio com o nome de MaquinaEstado no Dev-C++.

Acrescente os seguintes arquivos no projeto:

- Main.cpp
- Global.h
- Maquina.cpp
- Maquina.h
- Estado.cpp
- Estado.h
- Estados.cpp
- Estados.h

Pronto, agora pegue o arquivo Main.cpp e inclua o seguinte código nele:

#include “Global.h”int main()
{
// Loop principal do jogo
while(1)
{
// Implementação dos eventos: teclas ESC, j e q, e evento de tempo terminado
if(kbhit())
{
int tecla = getch();
if(tecla == 27) // tecla ESC
{
break;
}
} //Limpa a tela // Desenha na tela printf(”Loop”);
}
return 0;
}

Pegue o arquivo Global.h e insira o seguinte código:

#include <stdio.h>
#include <conio.h>
#include <stdlib.h>
#include <time.h>


Como eu disse, não estaremos usando nada gráfico aqui.Compile o programa e execute. Esse programa é um modelo simplificado de jogo. Você pode observar no código de Main.cpp o loop principal da aplicação que espera entradas no teclado, comando kbhit() e getch(). Caso ocorra algum pressionar de botão, o programa armazena a tecla pressionada na variável específica e caso a tecla pressionada seja um ESC, o loop é interrompido com o comando break.Mais a frente no código, podemos encontrar um comentario simulando a limpeza no buffer da tela, que poderia ser feito com uma chamada ao sistema operacional, usando system mais um cls, e podemos encontrar o comando printf() que simula o desenhar da tela. Tudo isso dentro de um modelo simplificado que tem como objetivo excluir a complexidade do desenvolvimento de um jogo para que possamos focar o problema na implementação de uma FSM.No código de Global.h você pode encontrar as bibliotecas padrão do C que estamos usando nessa aplicação.Com essa aplicação, nós já podemos receber 3 dos eventos que acionam as transições de estado da máquina:- Pressionar da tecla “j” para jogar - Pressionar da tecla “q” para sair - Pressionar da tecla “ESC”Está faltando somente um dos eventos relacionados anteriormente no tutorial:- limite de tempo de 5 segundos alcançado após a entrada da aplicaçãoAcrescente o código em vermelho na função main para podermos monitorar o tempo:

#include “Global.h”int main()
{
// Tempo
time_t timer_start;
time_t timer_current;
timer_start = time(NULL);double diff = 0; // Loop principal do jogo
while(1)
{
timer_current = time(NULL);
diff = difftime(timer_current, timer_start); if(kbhit())
{
int tecla = getch();
if(tecla == 27)
{
break;
}
}
// Desenha na tela
printf(”Loop: %4.2f\n”, diff);
}
return 0;
}

Pronto, a partir de agora somos capazes de capturar qualquer evento necessário para que as transições existentes no nosso modelo sejam acionadas. Está na hora de começar a implementar os estados da máquina.Segundo Erich Gamma, inicialmente, temos que implementar uma classe abstrata chamada, no meu caso, de Estado e essa classe, basicamente, irá implementar uma interface comum para todos os estados do jogo. As subclasses de Estado, implementam, então, comportamentos específicos a cada um destes estados.O modelo não é complicado. Segue abaixo um diagrama de classes do modelo que irei implementar baseado no livro do Gamma.Nesse modelo, a classe Maquina mantém uma instância da classe Estado e a utiliza para executar operações específicas ao estado do jogo.Toda vez que um estado muda, ou melhor, quando ocorre uma transição de estados, a instância de Estado da classe Maquina muda.Primeiramente, vamos observar o código da classe Estado, pai de todos os estados do jogo. Abra o arquivo Estado.h e acrescente o código abaixo:

#ifndef ESTADO_H
#define ESTADO_H
class Estado
{
public:
// Eventos de mudança de estado
virtual void AoPressionarJogar(Maquina* maquina) {};
virtual void AoPressionarSair(Maquina* maquina) {};
virtual void AoPressionarESC(Maquina* maquina) {};
virtual void AoTerminarTempo(Maquina* maquina) {};
// Executa o evento de entrada do estado
virtual void AoEntrar() {};
// Executa um frame de animação do estado atual
virtual void ExecutaFrame() {};
// Executa o evento de saída do estado
virtual void AoSair() {};
protected:
void ExecutaTransicao(Maquina* maquina, Estado* estado);
};
#endif

A nossa classe Estado possui um método para cada evento tratado pela máquina. Esses métodos são abstratos (virtual) e podem ser reescritos nas classes filhas de Estado, ou seja, cada um dos estados do jogo terá o tratamento de um evento específico para o seu caso. Lembrando que não existe a necessidade de implementação de todos os métodos de evento na classe filha, vou explicar isso mais a frente quando eu mostrar a implementação dessa classe.Como meus estados são estados de um jogo, antes de executar o frame de animação, eu preciso instanciar todos os objetos que irei utilizar na tela e os meus controladores de objetos, além de outras coisas. Por isso que crio dois eventos de estado que são: AoEntrar() e AoSair(). No momento em que executo uma transição de estados, eu faço uma chamada ao método AoSair() do estado atual, pois a máquina irá sair desse estado no momento da transição e após que ocorrer a transição eu faço uma chamada ao método AoEntrar() do estado atual, pois o estado foi alterado para um novo estado. Esses dois métodos são chamados antes e depois da transição e eles são responsáveis por instanciar e liberar os recursos usados no Estado em questão.O método ExecutaFrame() é usado para a lógica contida no loop do jogo referente a um estado específico. É nesse método que eu, por exemplo, no EstadoJogo desenho e atualizo o jogador, os inimigos, os tiros, etc.Por último, o método ExecutaTransicao() é utilizado para chamar os métodos AoSair() e AoEntrar() e para executar a transição de estados em si. Ele é implementado na classe pai Estado, pois a lógica deve ser a mesma para todos os estados filhos desta classe.Vamos observar o código de implementação dessa classe Estado:

#include “Global.h”void Estado::ExecutaTransicao(Maquina* maquina, Estado* estado)
{
maquina->ExecutaTransicao(estado);
}

O único método que temos que implementar aqui é o método ExecutaTransicao(). Ao ser chamado, ele informa à máquina, através do método maquina->ExecutaTransicao(estado) o novo estado que a máquina vai assumir como estado corrente.Vamos observar o codigo da classe Maquina. Abra o arquivo Maquina.h e inclua o seguinte código nele:

#ifndef MAQUINA_H
#define MAQUINA_H
class Estado;class Maquina
{
private:
// Instância única da classe (Singleton)
static Maquina* m_pInstancia;
// Estado atual
class Estado* m_pEstadoAtual;
// Indica se a máquina tem que parar
bool m_Finalizar;
public:
// Construtor da classe
Maquina();
// Cria a instância única da classe (Singleton)
static Maquina* CriaInstancia();
// Executa um frame de animação do estado atual
void ExecutaFrame();
// Executa transição de estados
void ExecutaTransicao(Estado* estado);
// Finaliza a máquina
void Finalizar();
// Pergunta se pode finalizar a máquina
bool PodeFinalizar();
// Eventos de mudança de estado
void AoPressionarJogar();
void AoPressionarSair();
void AoPressionarESC();
void AoTerminarTempo();
};
#endif

Nesse fragmento acima nós temos a classe Maquina que irá controlar as mudanças de estado do meu jogo. Ao criar o objeto Maquina, eu utilizarei o padrão Singleton garantindo que eu tenha somente uma instância dessa classe em toda a execução da minha aplicação. Esse padrão não me deixa cometer o erro de criar duas instâncias dessa classe. Para aprender mais sobre o Singleton consulte o livro do Gamma ou procure na Internet, existem vários sites que mostram como implementá-lo. Esse padrão será assunto de um tutorial futuro aqui na Schifer. O atributo m_pInstancia aponta para a instância única da máquina e o método CriaInstancia() é o responsável pela criação da instância única.O segundo atributo da classe Maquina é uma referência para o estado corrente do meu jogo: m_pEstadoAtual.Em seguida, eu coloco um atributo booleano que informa se a máquina irá finalizar ou não. Caso esse atributo assuma um valor verdadeiro, a máquina irá informar à aplicação que o loop principal deve ser interrompido através de um comando break. Quem informa a aplicação é o método PodeFinalizar(). Esse método é chamado no meio do loop principal. Existe ainda um método Finalizar() que pode ser chamado em qualquer lugar da aplicação informando que a partir de agora, a máquina pode interromper o fluxo de execução, ou seja, esse método informa que já pode finalizar e o método PodeFinalizar() só responde a pergunta, pois em um ponto específico da execução, precisamos perguntar se podemos ou não executar o comando break.Temos, então, o construtor da classe que limpa os ponteiros e faz com que a variável m_Finalizar receba falso, pois quando for verdadeiro, ela irá terminar a execução do loop principal.Após o construtor, temos o método CriaInstancia() do Singleton.Depois do método do Singleton, temos o método ExecutaFrame(). Ele é responsável por chamar o método ExecutaFrame() do estado corrente.Os dois métodos seguintes são os métodos de finalização da máquina já explicado.Por último, nós temos os métodos que lançam os eventos que a máquina trata. Para cada evento tratado, deve existir um método de lançamento desse evento. Eu poderia trocar esses nomes por nomes mais conceituais de jogo, mas para manter a complexidade baixa, eu chamei os métodos com os nomes das teclas que serão pressionadas, mas isso acabe a você decidir como irá implementar.Vamos observar o código de implementação dos métodos acima:

#include “Global.h”Maquina::Maquina()
{
m_pEstadoAtual = 0;
m_Finalizar = false;
}
// Definição do atributo instância
Maquina* Maquina::m_pInstancia = 0;
// Cria a instância única da classe (Singleton)
Maquina* Maquina::CriaInstancia()
{
if(m_pInstancia == 0)
{
m_pInstancia = new Maquina();
}
return m_pInstancia;
}
// Executa uma transição de estado
void Maquina::ExecutaTransicao(Estado* estado)
{
// Executa o evento AoSair() do estado antigo antes de executar a transição
if(m_pEstadoAtual != 0)
{
m_pEstadoAtual->AoSair();
}
m_pEstadoAtual = estado;// Executa o evento AoEntrar() do estado novo logo após executar a transição
m_pEstadoAtual->AoEntrar();
}
// Executa um frame de animação do estado atual
void Maquina::ExecutaFrame()
{
m_pEstadoAtual->ExecutaFrame();
}
void Maquina::Finalizar()
{
m_Finalizar = true;
}
bool Maquina::PodeFinalizar()
{
return m_Finalizar;
}
// Eventos de mudança de estado
void Maquina::AoPressionarJogar()
{
m_pEstadoAtual->AoPressionarJogar(this);
}

void Maquina::AoPressionarSair()
{
m_pEstadoAtual->AoPressionarSair(this);
}

void Maquina::AoPressionarESC()
{
m_pEstadoAtual->AoPressionarESC(this);
}

void Maquina::AoTerminarTempo()
{
m_pEstadoAtual->AoTerminarTempo(this);
}

O código começa com o construtor que já foi explicado anteriormente, assim como o próximo método que é o responsável pela criação do Singleton.O método ExecutaTransicao() é o responsável pela troca de estados. Existe um teste inicial que verifica se o valor do estado corrente é zero, pois se for, um erro será lançado ao se tentar usar um método a partir de um ponteiro para objeto vazio. Esse teste é necessário, pois não podemos chamar o método OnSair() na primeira vez que estivermos executando a máquina. Em seguida ele muda a referência do estado atual e logo em seguida chama o método OnEntrar() para o novo objeto que o ponteiro estará referenciando.Após trocar o estado, temos o método ExecutaFrame() que como foi explicado anteriormente, chama o ExecutaFrame() do estado atual.Temos então os métodos que fazem o controle de finalização da máquina, já explicados, e os métodos que implementam os eventos que a máquina trata. Todos eles chamam seus respectivos métodos nas classes filhas. É nas classes filhas que implementamos a lógica das transições, pois dependendo do evento que ocorrer e do estado atual, o próprio estado indica qual o próximo estado que a máquina assumirá.Vamos implementar agora os 3 estados deste jogo. Copie o código seguinte para o arquivo Estados.h.

#ifndef ESTADOS_H
#define ESTADOS_H
class EstadoApresentacao : public Estado
{
private:
// Instância única da classe (Singleton)
static EstadoApresentacao* m_pInstancia;
public:
// Cria a instância única da classe (Singleton)
static EstadoApresentacao* CriaInstancia();
void AoEntrar();void ExecutaFrame();void AoSair();// Eventos de mudança de estado
void AoTerminarTempo(Maquina* maquina);
};
class EstadoMenu : public Estado
{
private:
// Instância única da classe (Singleton)
static EstadoMenu* m_pInstancia;
public:
// Cria a instância única da classe (Singleton)
static EstadoMenu* CriaInstancia();
void AoEntrar();void ExecutaFrame();void AoSair();// Eventos de mudança de estado
void AoPressionarJogar(Maquina* maquina);
void AoPressionarSair(Maquina* maquina);
};
class EstadoJogo : public Estado
{
private:
// Instância única da classe (Singleton)
static EstadoJogo* m_pInstancia;
public:
// Cria a instância única da classe (Singleton)
static EstadoJogo* CriaInstancia();

void AoEntrar();

void ExecutaFrame();

void AoSair();

// Eventos de mudança de estado
void AoPressionarESC(Maquina* maquina);
};

#endif

Como você pode visualizar no código, todos os meus estados são um Singleton, isso é recomendação do Erich Gamma em seu livro. Portanto, os 3 estados terão os métodos e atributos referentes ao padrão Singleton. O método é o CriaInstancia() e o atributo é o m_pInstancia. Você pode notar que todos os estados possuem esse método e esse atributo.Em seguida temos a declaração dos 3 métodos que foram declarados abstratos na classe Estado: AoSair(), ExecutarFrame() e AoEntrar(). Cada um desses métodos já foi explicado anteriormente, mas o que vou acrescentar é que cada um dos estados possui uma lógica sua de sequência de tarefas que serão executadas, ou seja, a lógica é específica para cada estado. Eu mostrarei isso na implementação do estado.Após declarar os métodos acima, encontramos os eventos da máquina tratados por um estado específico. Os estados não implementam todos os eventos, mas somente aqueles que será capaz de tratar. Por isso que no primeiro estado, você encontra o método AoTerminarTempo() e nos outros estados não. Esse método só é necessário no estado apresentação de acordo com as definições de nosso projeto. Isso vale para os outros estados também, você verá que cada um implementa os eventos necessários para ele.Agora, observe a implementação da classe acima. Acrescente o código abaixo no arquivo Estados.cpp:

#include “Global.h”EstadoApresentacao* EstadoApresentacao::m_pInstancia = 0;EstadoApresentacao* EstadoApresentacao::CriaInstancia()
{
if(m_pInstancia == 0)
{
m_pInstancia = new EstadoApresentacao();
}
return m_pInstancia;
}
void EstadoApresentacao::AoEntrar()
{
}
void EstadoApresentacao::ExecutaFrame()
{
printf(”\nApresentacao\n”);
}
void EstadoApresentacao::AoSair()
{
}
void EstadoApresentacao::AoTerminarTempo(Maquina* maquina)
{
ExecutaTransicao(maquina, EstadoMenu::CriaInstancia());
}
EstadoMenu* EstadoMenu::m_pInstancia = 0;EstadoMenu* EstadoMenu::CriaInstancia()
{
if(m_pInstancia == 0)
{
m_pInstancia = new EstadoMenu();
}
return m_pInstancia;
}
void EstadoMenu::AoEntrar()
{
}

void EstadoMenu::ExecutaFrame()
{
printf(”\nMenu\n”);
printf(”\n- Pressione ‘j’ para jogar”);
printf(”\n- Pressione ‘q’ para sair\n\n”);
}

void EstadoMenu::AoSair()
{
}

void EstadoMenu::AoPressionarSair(Maquina* maquina)
{
maquina->Finalizar();
}

void EstadoMenu::AoPressionarJogar(Maquina* maquina)
{
ExecutaTransicao(maquina, EstadoJogo::CriaInstancia());
}

EstadoJogo* EstadoJogo::m_pInstancia = 0;

EstadoJogo* EstadoJogo::CriaInstancia()
{
if(m_pInstancia == 0)
{
m_pInstancia = new EstadoJogo();
}

return m_pInstancia;
}

void EstadoJogo::AoEntrar()
{
}

void EstadoJogo::ExecutaFrame()
{
printf(”\nJogo\n”);
printf(”\nPressione ‘ESC’ para sair”);
}

void EstadoJogo::AoSair()
{
}

void EstadoJogo::AoPressionarESC(Maquina* maquina)
{
ExecutaTransicao(maquina, EstadoMenu::CriaInstancia());
}

No código acima, nós vemos o padrão Singleton implementado para cada um dos estados (já foi explicado anteriormente). Em seguida, não precisei implementar código para os eventos AoSair() e AoEntrar(), pois a simplicidade do meu projeto não exige inicialização de objetos nos estados, ao contrário do método ExecutaFrame(). Esse último necessita de um código específico, pois aqui eu imprimo a mensagem que indica na tela qual estado está rodando nesse momento.Em seguida, nós encontraremos a implementação dos eventos. Basicamente, nesse tutorial, a lógica da transição de um estado é simplesmente passar a instância da máquina e a instância do novo estado. O método ExecutaTransicao() implementado na classe Estado pai é então chamado recebendo a máquina e o novo estado. Ele chama então, o método ExecutaTransicao() da máquina recebendo a instância do novo estado. Esse método pegará essa instância e indicará que o seu atributo m_pEstadoAtual irá receber a referência para essa instância. Dessa forma, o estado atual muda e a máquina assume um novo estado concreto.Para terminar o tutorial, acrescente o texto em vermelho abaixo no arquivo Global.h e tente compilar o programa.

#include <stdio.h>
#include <conio.h>
#include <stdlib.h>
#include <time.h>
#include “Maquina.h”
#include “Estado.h”
#include “Estados.h”

Se você quiser fazer o download do projeto com o código fonte completo deste tutorial no Dev-C++ clique aqui.Com isso, nós terminamos mais um tutorial da Schifer. Se você seguiu o tutorial inteiro e gostou ou não gostou do que leu, tem dúvidas sobre o que leu, ou quer apenas me mandar uma mensagem, sinta-se livre para me enviar um e-mail. Meu e-mail é schifers@hotmail.com. Agradeço a atenção e espero que vocês tenham entendido tudo o que foi explicado aqui. Um abraço a todos e até o próximo tutorial.Bruno Schifer

3 Responses to “Padrões de Projetos para Jogos - Máquina de Estado”

  1. ViniGodoy Says:

    Muito bom o artigo. Acho que FSMs estão entre as coisas mais importantes em GameDev. Também são bastante úteis em telecomunicações. Ficou bacana vocês terem mostrado o State Design Pattern.

    Achei uma pena vocês ainda recomendarem o DevCpp. Depois da versão oficial do Code::Blocks, não vejo porque alguém deveria usar uma IDE com mais de 2 anos de idade, com um compilador velho, com um depurador que não funciona direito e que não está sendo mantida por ninguém.

    Quem gostou das FSMs, recomendo fortemente a leitura desse material também:
    http://www.ai-junkie.com/architecture/state_driven/tut_state1.html

    É um capítulo do livro Programming Game AI by Example, do Mat Buckland e fala exclusivamente sobre máquina de estados em jogos. Ainda é possível baixar alguns códigos de exemplo, que são bem divertidos.

  2. Bruno Schifer Says:

    Pessoal,

    Me desculpem pelo código mal indentado, pois a ferramenta utilizada apresentou alguns problemas com o meu código de tabelas. Eu recomendo que esse código aí seja utilizado somente como referência. É melhor baixar o arquivo ZIP cujo link está no final do tutorial. Se preferir ainda, veja o post no meu blog. Lá está tudo direitinho e já está atualizado com algumas correções. Desculpem mais uma vez e espero que ao menos o tutorial tenha ajudado em alguma coisa.

  3. Bruno Schifer Says:

    Mas o código pode ser perfeitamente compilado no Codeblocks. O código está em c++ e não deveria, inicialmente, apresentar nenhum problema quando compilado no Codeblocks. Eu já estou escrevendo meus novos tutoriais para o Codeblocks, porém esse é anterior a essa minha mudança de IDE. Caso alguém tenha algum problema em compilar esse código no codeblocks, me avise. Aqui segue um link para o download do projeto do tutorial para o Codeblocks:

    http://www.brunoschifer.blog.br/schifers/files/tutorialCodeblocks.zip

Leave a Reply