使用Scala和LWJGL的简化游戏的函数式编程方法


11

我是一名Java命令程序员,我想了解如何基于功能性编程设计原则(特别是引用透明性)生成简单版本的Space Invaders。但是,每当我尝试考虑一种设计时,我都会迷失在极端可变性的泥潭中,而这种可变性是函数式编程纯粹主义者所避免的。

为了学习函数式编程,我决定尝试使用LWJGLScala中创建一个非常简单的2D交互式游戏Space Invader(注意缺少复数形式)。以下是基本游戏的要求:

  1. 用户在屏幕底部分别通过“ A”和“ D”键向左和向右移动

  2. 用户发射的子弹由空格键直接向上发射,两次射击之间的最小间隔为0.5秒

  3. 两次射击之间的随机时间为0.5到1.5秒,直接发射外星飞弹

在原始游戏中故意遗漏的是WxH外星人,可降解的防御屏障x3,屏幕顶部的高速飞碟船。

好的,现在到实际的问题域。对我来说,所有确定性部分都是显而易见的。那些不确定的部分似乎阻碍了我思考方法的能力。确定性部分是子弹一旦存在的轨迹,外星人的连续运动以及由于击中玩家的船或外星人(或两者)而引起的爆炸。对我来说,不确定的部分正在处理用户输入流,处理用于确定外来子弹射击的随机值以及处理输出(图形和声音)。

这些年来,我可以做(并且已经做过很多)这类游戏。但是,所有这些都来自命令式范式。而且LWJGL甚至提供了一个非常简单的Java版的“太空侵略者”(我开始使用Scala作为Java(不带分号)将其迁移到Scala)。

以下是围绕该领域讨论的一些链接,似乎没有一个人以Java / Imperative编程人员会理解的方式直接处理这些想法:

  1. 纯功能复古游戏,第1部分,詹姆斯·海格(James Hague)

  2. 类似的堆栈溢出帖子

  3. Clojure / Lisp游戏

  4. Haskell游戏堆栈溢出

  5. Yampa(在Haskell中)功能响应式编程

It appears that there are some ideas in the Clojure/Lisp and Haskell games (with source). Unfortunately, I am not able to read/interpret the code into mental models that make any sense to my simpleminded Java imperative brain.

我对FP提供的可能性感到非常兴奋,我可以品尝到多线程可伸缩性功能。我觉得如果能够弄清楚如何实现像“太空侵略者”的时间+事件+随机性模型这样简单的东西,将确定性和非确定性部分隔离在一个经过适当设计的系统中,而又不会变成高级数学理论的感觉; 即Yampa,我会被设置的。如果需要学习Yampa的理论水平才能成功生成简单的游戏,那么获得所有必要的培训和概念框架的开销将远远超过我对FP的了解(至少对于这个过于简化的学习实验而言) )。

任何反馈,提出的模型,解决问题领域的建议方法(比James Hague所涵盖的一般性都更为具体)将不胜感激。


1
我已从问题中删除了有关您的博客的部分,因为它对问题本身不是必不可少的。当您开始撰写后续文章时,可以随意添加其链接。
尼斯2012年

@Yannis-明白了。Tyvm!
chaotic3quilibrium 2012年

您要求提供Scala,这就是为什么这只是一条评论。《 Clojure的洞穴》是关于如何实施流氓FP风格的易于理解的书籍。它通过返回作者可以测试的世界快照来处理状态。太酷了。也许您可以浏览这些帖子,看看他实施的任何部分是否都可以轻松转移到Scala
IAE 2014年

Answers:


5

空间入侵者的惯用Scala / LWJGL实现看起来不像Haskell / OpenGL实现。在我看来,编写Haskell实现可能是更好的练习。但是,如果您想坚持使用Scala,这里有一些有关如何以功能样式编写它的想法。

尝试仅使用不可变的对象。您可能有一个Game对象,其中包含PlayerSet[Invader](一定要使用immutable.Set)等。给Player一个update(state: Game): Player(它也可以取depressedKeys: Set[Int]等),并为其他类提供类似的方法。

对于随机性,scala.util.Random它不是像Haskell一样不变的System.Random,但是您可以制作自己的不变的生成器。这是低效的,但却说明了这一想法。

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

对于键盘/鼠标输入和渲染,无法调用不纯函数。它们在Haskell中也是不纯的,它们只是封装在IOetc中,因此您的实际函数对象在技术上是纯净的(它们本身不读取或写入状态,它们描述了要执行的例程,并且运行时系统执行了这些例程) 。

只是不要把I / O的代码在你喜欢一成不变的对象GamePlayerInvader。您可以给出Player一个render方法,但是它看起来应该像

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

不幸的是,由于它是基于状态的,因此它不适用于LWJGL,但是您可以在其之上构建自己的抽象。您可能有一个ImmutableCanvas包含AWT 的类Canvas,并且它的blit(和其他方法)可以克隆基础Canvas,将其传递给Display.setParent,然后执行渲染并返回新的Canvas(在不可变的包装器中)。


更新:这是一些Java代码,显示我将如何处理。(我会用Scala编写几乎相同的代码,只是内置了一个不可变的集合,并且可以用地图或折叠代替一些for-each循环。)我做了一个可以四处走动并发射子弹的玩家,但是由于代码已经很长了,所以没有添加敌人。我几乎将所有内容都进行了写时复制 –我认为这是最重要的概念。

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) {}
    }
  }
}

2
我添加了一些Java代码-有帮助吗?如果代码看起来很奇怪,我将看一些不可变的,写时复制类的较小示例。看起来像是一个不错的解释。
Daniel Lubarov 2012年

2
@ chaotic3quilibrium,它只是一个普通的标识符。我有时会用它代替args代码是否忽略参数。很抱歉造成不必要的混乱。
Daniel Lubarov '02

2
别担心。我只是假设了,然后继续前进。我昨天玩了您的示例代码。我想我有主意。现在,我想知道我是否还缺少其他东西。临时对象的数量非常庞大。每个刻度都会生成一个显示GameState的框架。而要从上一个刻度的GameState转到该GameState,则涉及生成许多中间的GameState实例,每个实例都与前一个GameState进行一次小的调整。
chaotic3quilibrium

3
是的,这很浪费。我不认为这些GameState副本会那么昂贵,即使每个刻度都制作了几个副本,因为每个副本约32个字节。但是,ImmutableSet如果许多子弹同时存在,则复制s可能会很昂贵。我们可以ImmutableSet用树结构代替scala.collection.immutable.TreeSet以减轻问题。
丹尼尔·卢巴罗夫

2
而且ImmutableImage更糟糕的,因为它会将大量光栅时,它的修改。我们也可以做些事情来减轻这个问题,但是我认为以命令式的方式编写渲染代码是最实际的(即使是Haskell程序员也通常这样做)。
丹尼尔·卢巴罗夫

4

好吧,您正在通过使用LWJGL来限制自己的工作-对此没有什么反对,但是它将强加非功能性的习惯用法。

但是,您的研究符合我的建议。通过功能反应式编程或数据流编程等概念,功能编程中很好地支持“事件”。您可以尝试使用Reactive(一种用于Scala的FRP库)来查看它是否可以包含您的副作用。

另外,从Haskell中取出一页:使用monad封装/隔离副作用。查看状态和IO单子。


请输入Tyvm。我不确定如何从Reactive获取键盘/鼠标输入和图形/声音输出。在那里,我只是想念它吗?关于您使用monad的参考-我现在正在学习它们,但仍然不完全了解monad是什么。
chaotic3quilibrium 2012年

3

(对我而言)不确定的部分正在处理用户输入流……正在处理输出(图形和声音)。

是的,IO是不确定的,并且具有“所有有关”的副作用。在非纯功能语言(例如Scala)中,这不是问题。

处理获取用于确定外星人子弹射击的随机值

您可以将伪随机数生成器的输出视为无限序列(Seq在Scala中)。

...

您特别在哪里看到对可变性的需求?如果我可以预料到,您可能会认为您的精灵在空间中的位置会随时间变化。您可能会发现在这种情况下考虑“拉链”很有用:http : //scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php


我什至不知道如何构造初始代码,使它成为惯用的函数式编程。之后,我不了解添加“不纯”代码的正确(或首选)技术。我知道我可以将Scala用作“不带分号的Java”。我不想那样做。我想学习FP如何在不依赖时间或值可变性泄漏的情况下解决非常简单的动态环境。这有意义吗?
chaotic3quilibrium 2012年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.