Abordagem de programação funcional para um jogo simplificado usando Scala e LWJGL

11

Eu, um programador imperativo em Java, gostaria de entender como gerar uma versão simples do Space Invaders com base nos princípios de design da Programação Funcional (em particular Transparência Referencial). No entanto, toda vez que tento pensar em um design, me perco no pântano da extrema mutabilidade, a mesma mutabilidade que é evitada pelos puristas da programação funcional.

Como uma tentativa de aprender a Programação Funcional, decidi tentar criar um jogo interativo 2D muito simples, o Space Invader (observe a falta de plural), em Scala, usando o LWJGL . Aqui estão os requisitos para o jogo básico:

  1. O envio do usuário na parte inferior da tela foi movido para a esquerda e para a direita pelas teclas "A" e "D", respectivamente

  2. A bala de envio do usuário disparada diretamente para cima ativada pela barra de espaço com uma pausa mínima entre os disparos para ser de 0,5 segundos

  3. Bala de nave alienígena disparada para baixo ativada por um tempo aleatório de 0,5 a 1,5 segundos entre tiros

As coisas intencionalmente deixadas de fora do jogo original são alienígenas WxH, barreiras de defesa degradáveis ​​x3, disco voador de alta velocidade na parte superior da tela.

Ok, agora para o domínio do problema real. Para mim, todas as partes determinísticas são óbvias. São as partes não determinísticas que parecem estar bloqueando minha capacidade de considerar como abordar. As partes determinísticas são a trajetória da bala, uma vez que existem, o movimento contínuo do alien e a explosão devido a um golpe em um (ou em ambos) do navio do jogador ou no alien. As partes não determinísticas (para mim) estão lidando com o fluxo de entrada do usuário, buscando um valor aleatório para determinar disparos de balas alienígenas e lidando com a saída (gráficos e som).

Eu posso fazer (e já fiz) muito desse tipo de desenvolvimento de jogo ao longo dos anos. No entanto, tudo isso era do paradigma imperativo. E o LWJGL ainda fornece uma versão Java muito simples dos invasores do Space (da qual comecei a mudar para o Scala usando o Scala como Java sem ponto e vírgula).

Aqui estão alguns links que falam sobre essa área, dos quais nenhum parece ter lidado diretamente com as idéias de uma maneira que uma pessoa proveniente da programação Java / Imperative entenderia:

  1. Retrogames puramente funcionais, parte 1 de James Hague

  2. Postagem semelhante ao estouro de pilha

  3. Jogos de Clojure / Lisp

  4. Jogos de Haskell no estouro de pilha

  5. Programação Reativa Funcional da Yampa (em Haskell)

Parece que existem algumas idéias nos jogos Clojure / Lisp e Haskell (com fonte). Infelizmente, não sou capaz de ler / interpretar o código em modelos mentais que fazem algum sentido para o meu cérebro imperativo Java simplório.

Estou tão empolgado com as possibilidades oferecidas pelo FP que posso apenas provar os recursos de escalabilidade multithread. Sinto como se fosse capaz de entender como algo tão simples como o modelo de tempo + evento + aleatoriedade para o Space Invader pode ser implementado, segregando as partes determinísticas e não determinísticas em um sistema projetado adequadamente, sem que isso se transforme no que parece uma teoria matemática avançada ; Yampa, eu estaria pronto. Se é necessário aprender o nível de teoria que o Yampa exige para gerar jogos simples com sucesso, a sobrecarga de adquirir todo o treinamento e a estrutura conceitual necessários superará amplamente a minha compreensão dos benefícios do FP (pelo menos para este experimento de aprendizagem simplificado) )

Qualquer feedback, modelos propostos, métodos sugeridos para abordar o domínio do problema (mais específicos do que as generalidades cobertas por James Hague) seriam muito apreciados.

chaotic3quilibrium
fonte
1
Eu removi a parte do seu blog da pergunta, porque não era essencial para a pergunta em si. Sinta-se à vontade para incluir um link para um artigo de acompanhamento ao redigir.
yannis
@Yannis - Entendi. Tyvm!
chaotic3quilibrium
Você pediu Scala, e é por isso que isso é apenas um comentário. Caves of Clojure é uma leitura gerenciável sobre como implementar um estilo FP semelhante a um roguel. Ele lida com o estado retornando um instantâneo do mundo que o autor pode testar. Isso é bem legal. Talvez você pode navegar através das mensagens e ver se quaisquer partes de sua aplicação são facilmente transferíveis para Scala
IAE

Respostas:

5

Uma implementação idiomática de Scala / LWJGL do Space Invaders não se pareceria muito com uma implementação de Haskell / OpenGL. Escrever uma implementação Haskell pode ser um exercício melhor na minha opinião. Mas se você quiser ficar com o Scala, aqui estão algumas idéias de como escrevê-lo em estilo funcional.

Tente usar apenas objetos imutáveis. Você pode ter um Gameobjeto que contém a Player, a Set[Invader](certifique-se de usar immutable.Set), etc. Dê Playerum update(state: Game): Player(também pode levar depressedKeys: Set[Int], etc.) e dê às outras classes métodos semelhantes.

Por acaso, scala.util.Randomnão é imutável como o de Haskell System.Random, mas você pode criar seu próprio gerador imutável. Este é ineficiente, mas demonstra a ideia.

case class ImmutablePRNG(val seed: Long) extends Immutable {
    lazy val nextLong: (Long, ImmutableRNG) =
        (seed, ImmutablePRNG(new Random(seed).nextLong()))
    ...
}

Para entrada e renderização de teclado / mouse, não há como evitar funções impuras. Eles também são impuros em Haskell, apenas encapsulados em IOetc., para que seus objetos de função reais sejam tecnicamente puros (eles não lêem ou escrevem eles mesmos, descrevem rotinas que o fazem e o sistema de tempo de execução executa essas rotinas) .

Apenas não coloque código de E / S em seus objetos imutáveis ​​como Game, Playere Invader. Você pode dar Playerum rendermétodo, mas deve parecer

render(state: Game, buffer: Image): Image

Infelizmente, isso não se encaixa bem com o LWJGL, pois é tão baseado em estado, mas você pode criar suas próprias abstrações sobre ele. Você pode ter uma ImmutableCanvasclasse que possui um AWT Canvase seus blit(e outros métodos) podem clonar o subjacente Canvas, passá-lo para Display.setParent, executar a renderização e retornar o novo Canvas(em seu invólucro imutável).


Atualização : Aqui está um código Java mostrando como eu faria isso. (Eu teria escrito quase o mesmo código em Scala, exceto que um conjunto imutável está embutido e alguns para cada loops poderiam ser substituídos por mapas ou dobras.) Eu criei um jogador que se move e dispara balas, mas eu não adicionou inimigos, pois o código já estava ficando longo. Eu fiz praticamente tudo de copiar em escrever - acho que esse é o conceito mais importante.

import java.awt.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.*;

import static java.awt.event.KeyEvent.*;

// An immutable wrapper around a Set. Doesn't implement Set or Collection
// because that would require quite a bit of code.
class ImmutableSet<T> implements Iterable<T> {
  final Set<T> backingSet;

  // Construct an empty set.
  ImmutableSet() {
    backingSet = new HashSet<T>();
  }

  // Copy constructor.
  ImmutableSet(ImmutableSet<T> src) {
    backingSet = new HashSet<T>(src.backingSet);
  }

  // Return a new set with an element added.
  ImmutableSet<T> plus(T elem) {
    ImmutableSet<T> copy = new ImmutableSet<T>(this);
    copy.backingSet.add(elem);
    return copy;
  }

  // Return a new set with an element removed.
  ImmutableSet<T> minus(T elem) {
    ImmutableSet<T> copy = new ImmutableSet<T>(this);
    copy.backingSet.remove(elem);
    return copy;
  }

  boolean contains(T elem) {
    return backingSet.contains(elem);
  }

  @Override public Iterator<T> iterator() {
    return backingSet.iterator();
  }
}

// An immutable, copy-on-write wrapper around BufferedImage.
class ImmutableImage {
  final BufferedImage backingImage;

  // Construct a blank image.
  ImmutableImage(int w, int h) {
    backingImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
  }

  // Copy constructor.
  ImmutableImage(ImmutableImage src) {
    backingImage = new BufferedImage(
        src.backingImage.getColorModel(),
        src.backingImage.copyData(null),
        false, null);
  }

  // Clear the image.
  ImmutableImage clear(Color c) {
    ImmutableImage copy = new ImmutableImage(this);
    Graphics g = copy.backingImage.getGraphics();
    g.setColor(c);
    g.fillRect(0, 0, backingImage.getWidth(), backingImage.getHeight());
    return copy;
  }

  // Draw a filled circle.
  ImmutableImage fillCircle(int x, int y, int r, Color c) {
    ImmutableImage copy = new ImmutableImage(this);
    Graphics g = copy.backingImage.getGraphics();
    g.setColor(c);
    g.fillOval(x - r, y - r, r * 2, r * 2);
    return copy;
  }
}

// An immutable, copy-on-write object describing the player.
class Player {
  final int x, y;
  final int ticksUntilFire;

  Player(int x, int y, int ticksUntilFire) {
    this.x = x;
    this.y = y;
    this.ticksUntilFire = ticksUntilFire;
  }

  // Construct a player at the starting position, ready to fire.
  Player() {
    this(SpaceInvaders.W / 2, SpaceInvaders.H - 50, 0);
  }

  // Update the game state (repeatedly called for each game tick).
  GameState update(GameState currentState) {
    // Update the player's position based on which keys are down.
    int newX = x;
    if (currentState.keyboard.isDown(VK_LEFT) || currentState.keyboard.isDown(VK_A))
      newX -= 2;
    if (currentState.keyboard.isDown(VK_RIGHT) || currentState.keyboard.isDown(VK_D))
      newX += 2;

    // Update the time until the player can fire.
    int newTicksUntilFire = ticksUntilFire;
    if (newTicksUntilFire > 0)
      --newTicksUntilFire;

    // Replace the old player with an updated player.
    Player newPlayer = new Player(newX, y, newTicksUntilFire);
    return currentState.setPlayer(newPlayer);
  }

  // Update the game state in response to a key press.
  GameState keyPressed(GameState currentState, int key) {
    if (key == VK_SPACE && ticksUntilFire == 0) {
      // Fire a bullet.
      Bullet b = new Bullet(x, y);
      ImmutableSet<Bullet> newBullets = currentState.bullets.plus(b);
      currentState = currentState.setBullets(newBullets);

      // Make the player wait 25 ticks before firing again.
      currentState = currentState.setPlayer(new Player(x, y, 25));
    }
    return currentState;
  }

  ImmutableImage render(ImmutableImage img) {
    return img.fillCircle(x, y, 20, Color.RED);
  }
}

// An immutable, copy-on-write object describing a bullet.
class Bullet {
  final int x, y;
  static final int radius = 5;

  Bullet(int x, int y) {
    this.x = x;
    this.y = y;
  }

  // Update the game state (repeatedly called for each game tick).
  GameState update(GameState currentState) {
    ImmutableSet<Bullet> bullets = currentState.bullets;
    bullets = bullets.minus(this);
    if (y + radius >= 0)
      // Add a copy of the bullet which has moved up the screen slightly.
      bullets = bullets.plus(new Bullet(x, y - 5));
    return currentState.setBullets(bullets);
  }

  ImmutableImage render(ImmutableImage img) {
    return img.fillCircle(x, y, radius, Color.BLACK);
  }
}

// An immutable, copy-on-write snapshot of the keyboard state at some time.
class KeyboardState {
  final ImmutableSet<Integer> depressedKeys;

  KeyboardState(ImmutableSet<Integer> depressedKeys) {
    this.depressedKeys = depressedKeys;
  }

  KeyboardState() {
    this(new ImmutableSet<Integer>());
  }

  GameState keyPressed(GameState currentState, int key) {
    return currentState.setKeyboard(new KeyboardState(depressedKeys.plus(key)));
  }

  GameState keyReleased(GameState currentState, int key) {
    return currentState.setKeyboard(new KeyboardState(depressedKeys.minus(key)));
  }

  boolean isDown(int key) {
    return depressedKeys.contains(key);
  }
}

// An immutable, copy-on-write description of the entire game state.
class GameState {
  final Player player;
  final ImmutableSet<Bullet> bullets;
  final KeyboardState keyboard;

  GameState(Player player, ImmutableSet<Bullet> bullets, KeyboardState keyboard) {
    this.player = player;
    this.bullets = bullets;
    this.keyboard = keyboard;
  }

  GameState() {
    this(new Player(), new ImmutableSet<Bullet>(), new KeyboardState());
  }

  GameState setPlayer(Player newPlayer) {
    return new GameState(newPlayer, bullets, keyboard);
  }

  GameState setBullets(ImmutableSet<Bullet> newBullets) {
    return new GameState(player, newBullets, keyboard);
  }

  GameState setKeyboard(KeyboardState newKeyboard) {
    return new GameState(player, bullets, newKeyboard);
  }

  // Update the game state (repeatedly called for each game tick).
  GameState update() {
    GameState current = this;
    current = current.player.update(current);
    for (Bullet b : current.bullets)
      current = b.update(current);
    return current;
  }

  // Update the game state in response to a key press.
  GameState keyPressed(int key) {
    GameState current = this;
    current = keyboard.keyPressed(current, key);
    current = player.keyPressed(current, key);
    return current;
  }

  // Update the game state in response to a key release.
  GameState keyReleased(int key) {
    GameState current = this;
    current = keyboard.keyReleased(current, key);
    return current;
  }

  ImmutableImage render() {
    ImmutableImage img = new ImmutableImage(SpaceInvaders.W, SpaceInvaders.H);
    img = img.clear(Color.BLUE);
    img = player.render(img);
    for (Bullet b : bullets)
      img = b.render(img);
    return img;
  }
}

public class SpaceInvaders {
  static final int W = 640, H = 480;

  static GameState currentState = new GameState();

  public static void main(String[] _) {
    JFrame frame = new JFrame() {{
      setSize(W, H);
      setTitle("Space Invaders");
      setContentPane(new JPanel() {
        @Override public void paintComponent(Graphics g) {
          BufferedImage img = SpaceInvaders.currentState.render().backingImage;
          ((Graphics2D) g).drawRenderedImage(img, new AffineTransform());
        }
      });
      addKeyListener(new KeyAdapter() {
        @Override public void keyPressed(KeyEvent e) {
          currentState = currentState.keyPressed(e.getKeyCode());
        }
        @Override public void keyReleased(KeyEvent e) {
          currentState = currentState.keyReleased(e.getKeyCode());
        }
      });
      setLocationByPlatform(true);
      setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      setVisible(true);
    }};

    for (;;) {
      currentState = currentState.update();
      frame.repaint();
      try {
        Thread.sleep(20);
      } catch (InterruptedException e) {}
    }
  }
}
Daniel Lubarov
fonte
2
Eu adicionei algum código Java - isso ajuda? Se o código parecer estranho, eu examinarei alguns exemplos menores de classes imutáveis ​​de cópia na gravação. Esta parece uma explicação decente.
Daniel Lubarov
2
@ chaotic3quilibrium é apenas um identificador normal. Às vezes eu o uso em vez de argsse o código ignorar argumentos. Desculpe pela confusão desnecessária.
Daniel Lubarov
2
Não se preocupe. Eu apenas assumi isso e segui em frente. Eu brinquei com seu código de exemplo por enquanto ontem. Eu acho que tenho o jeito da ideia. Agora, me pergunto se estou perdendo outra coisa. O número de objetos temporários é enorme. Cada tick gera um quadro que exibe um GameState. E para chegar ao GameState a partir do GameState do tick anterior, é necessário gerar várias instâncias do GameState intervenientes, cada uma com um pequeno ajuste do GameState anterior.
chaotic3quilibrium
3
Sim, é um grande desperdício. Eu não acho que as GameStatecópias seriam tão caras, mesmo que várias sejam feitas a cada tick, já que são ~ 32 bytes cada. Mas copiar os ImmutableSets pode ser caro se muitas balas estiverem vivas ao mesmo tempo. Poderíamos substituir ImmutableSetpor uma estrutura em árvore scala.collection.immutable.TreeSetpara diminuir o problema.
Daniel Lubarov
2
E ImmutableImageé ainda pior, pois copia uma grande varredura quando é modificada. Existem algumas coisas que poderíamos fazer para diminuir esse problema também, mas acho que seria mais prático escrever código de renderização em estilo imperativo (mesmo os programadores Haskell normalmente o fazem).
Daniel Lubarov
4

Bem, você está restringindo seus esforços usando o LWJGL - nada contra, mas isso impõe expressões não funcionais.

Sua pesquisa está alinhada com o que eu recomendaria, no entanto. Os "eventos" são bem suportados na programação funcional por meio de conceitos como programação reativa funcional ou programação de fluxo de dados. Você pode experimentar o Reactive , uma biblioteca de FRP para Scala, para ver se ele pode conter seus efeitos colaterais.

Além disso, tire uma página do Haskell: use mônadas para encapsular / isolar efeitos colaterais. Veja mônadas de estado e IO.

Daniel C. Sobral
fonte
Tyvm pela sua resposta. Não sei como obter a entrada do teclado / mouse e a saída de gráficos / som do Reactive. Está lá e só estou com saudades? Quanto à sua referência ao uso de uma mônada - agora estou aprendendo sobre elas e ainda não entendo completamente o que é uma mônada.
chaotic3quilibrium
3

As partes não determinísticas (para mim) estão lidando com o fluxo de entrada do usuário ... lidando com a saída (gráficos e som).

Sim, as E / S não são determinísticas e têm efeitos colaterais "tudo sobre". Isso não é um problema em uma linguagem funcional não pura como o Scala.

manipulação buscando um valor aleatório para determinar disparos de balas alienígenas

Você pode tratar a saída de um gerador de números pseudoaleatórios como uma sequência infinita ( Seqem Scala).

...

Onde, em particular, você vê a necessidade de mutabilidade? Se eu puder antecipar, você pode pensar em seus sprites como tendo uma posição no espaço que varia ao longo do tempo. Você pode achar útil pensar em "zíperes" nesse contexto: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php

Larry OBrien
fonte
Nem sei como estruturar o código inicial para que seja uma programação funcional idiomática. Depois disso, não entendo a técnica correta (ou preferida) para adicionar o código "impuro". Estou ciente de que posso usar o Scala como "Java sem ponto e vírgula". Eu não quero fazer isso. Quero aprender como o FP lida com um ambiente dinâmico muito simples sem depender de tempo ou vazamentos de mutabilidade de valor. Isso faz sentido?
chaotic3quilibrium