Pendekatan Pemrograman Fungsional untuk game yang disederhanakan menggunakan Scala dan LWJGL

11

Saya, seorang programmer imperatif Java, ingin memahami bagaimana membuat versi sederhana Space Invaders berdasarkan prinsip-prinsip desain Pemrograman Fungsional (khususnya Transparansi Referensial). Namun, setiap kali saya mencoba memikirkan suatu desain, saya tersesat di dalam tumpukan ketidakmampuan yang ekstrim, ketidakmampuan yang sama yang dijauhi oleh puritan pemrograman fungsional.

Sebagai upaya untuk mempelajari Pemrograman Fungsional, saya memutuskan untuk mencoba membuat game interaktif 2D yang sangat sederhana, Space Invader (perhatikan kurangnya jamak), di Scala menggunakan LWJGL . Berikut persyaratan untuk gim dasar:

  1. Kapal pengguna di bagian bawah layar bergerak masing-masing ke kiri dan kanan dengan tombol "A" dan "D"

  2. Peluru kapal pengguna diaktifkan lurus ke atas diaktifkan oleh space bar dengan jeda minimum antara tembakan menjadi 0,5 detik

  3. Peluru kapal asing yang ditembakkan langsung ke bawah diaktifkan dengan waktu acak 0,5 hingga 1,5 detik di antara tembakan

Hal-hal yang sengaja ditinggalkan dari permainan aslinya adalah alien WxH, perisai pertahanan x3 yang dapat didegradasi, kapal piring berkecepatan tinggi di bagian atas layar.

Oke, sekarang ke domain masalah aktual. Bagi saya, semua bagian deterministik sudah jelas. Ini adalah bagian-bagian non-deterministik yang tampaknya menghalangi kemampuan saya untuk mempertimbangkan cara pendekatan. Bagian-bagian deterministik adalah lintasan peluru begitu mereka ada, gerakan terus-menerus dari alien dan ledakan karena hit pada salah satu (atau keduanya) dari kapal pemain atau alien. Bagian non-deterministik (bagi saya) menangani aliran input pengguna, penanganan mengambil nilai acak untuk menentukan tembakan peluru alien dan menangani output (baik grafis dan suara).

Saya dapat melakukan (dan telah melakukan) banyak jenis pengembangan game ini selama bertahun-tahun. Namun, semua itu dari paradigma imperatif. Dan LWJGL bahkan menyediakan versi Java yang sangat sederhana dari Space Invaders (yang mana saya mulai pindah ke Scala menggunakan Scala sebagai Java-tanpa-titik koma).

Berikut adalah beberapa tautan yang berbicara tentang bidang ini yang tampaknya tidak ada yang secara langsung berurusan dengan ide-ide dengan cara yang dipahami oleh orang yang berasal dari Jawa / pemrograman Imperatif:

  1. Retrogames Murni Fungsional, Bagian 1 oleh James Hague

  2. Posting Stack Overflow serupa

  3. Game Clojure / Lisp

  4. Game Haskell di Stack Overflow

  5. Pemrograman Reaktif Fungsional Yampa (dalam Haskell)

Tampaknya ada beberapa ide di permainan Clojure / Lisp dan Haskell (dengan sumber). Sayangnya, saya tidak dapat membaca / menafsirkan kode menjadi model mental yang masuk akal untuk otak imperatif Java sederhana saya.

Saya sangat senang dengan kemungkinan yang ditawarkan oleh FP, saya bisa merasakan kemampuan skalabilitas multi-utas. Saya merasa seperti dapat memahami bagaimana sesuatu yang sederhana seperti waktu + peristiwa + model keacakan untuk Space Invader dapat diimplementasikan, memisahkan bagian-bagian deterministik dan non-deterministik dalam sistem yang dirancang dengan baik tanpa mengubahnya menjadi apa yang terasa seperti teori matematika canggih ; yaitu Yampa, aku akan siap. Jika mempelajari tingkat teori yang tampaknya diperlukan Yampa untuk menghasilkan permainan sederhana dengan sukses, maka biaya untuk memperoleh semua pelatihan dan kerangka kerja konseptual yang diperlukan akan jauh melebihi pemahaman saya tentang manfaat FP (setidaknya untuk eksperimen pembelajaran yang terlalu disederhanakan ini) ).

Setiap umpan balik, model yang diusulkan, metode yang disarankan untuk mendekati domain masalah (lebih spesifik daripada generalitas yang dicakup oleh James Hague) akan sangat dihargai.

chaotic3quilibrium
sumber
1
Saya telah menghapus bagian tentang blog Anda dari pertanyaan, karena itu tidak penting untuk pertanyaan itu sendiri. Jangan ragu untuk menyertakan tautan ke artikel tindak lanjut ketika Anda datang untuk menulisnya.
yannis
@Yannis - Paham. Tyvm!
chaotic3quilibrium
Anda meminta Scala, itulah mengapa ini hanya komentar. Caves of Clojure juga merupakan bacaan yang mudah dikelola tentang cara menerapkan gaya FP roguelike. Ia menangani keadaan dengan mengembalikan potret dunia yang kemudian dapat diuji oleh penulis. Itu keren sekali. Mungkin Anda dapat menelusuri posting dan melihat apakah ada bagian dari implementasinya yang mudah ditransfer ke Scala
IAE

Jawaban:

5

Implementasi Scala / LWJGL idiomatik dari Space Invaders tidak akan terlihat seperti implementasi Haskell / OpenGL. Menulis implementasi Haskell mungkin merupakan latihan yang lebih baik menurut saya. Tetapi jika Anda ingin tetap menggunakan Scala, berikut adalah beberapa ide cara menulisnya dalam gaya fungsional.

Coba gunakan benda yang tidak bisa diubah saja. Anda bisa memiliki Gameobjek yang memegang Player, seorang Set[Invader](pastikan untuk menggunakan immutable.Set), dll Berikan Playersebuah update(state: Game): Player(bisa juga mengambil depressedKeys: Set[Int], dll), dan memberikan kelas-kelas lain metode yang serupa.

Untuk keacakan, scala.util.Randomtidak kekal seperti milik Haskell System.Random, tetapi Anda bisa membuat generator sendiri yang tidak bisa diubah. Yang ini tidak efisien tetapi menunjukkan ide.

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

Untuk input dan rendering keyboard / mouse, tidak ada jalan lain untuk memanggil fungsi yang tidak murni. Mereka tidak murni di Haskell juga, mereka hanya dienkapsulasi dalam IOdll. Sehingga objek fungsi aktual Anda secara teknis murni (mereka tidak membaca atau menulis status sendiri, mereka menggambarkan rutinitas yang dilakukan, dan sistem runtime menjalankan rutinitas tersebut) .

Hanya saja, jangan menaruh kode I / O di objek abadi Anda seperti Game, Playerdan Invader. Anda dapat memberikan Playersuatu rendermetode, tetapi harus terlihat seperti

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

Sayangnya ini tidak cocok dengan LWJGL karena sangat berbasis negara, tetapi Anda dapat membangun abstraksi sendiri di atasnya. Anda bisa memiliki ImmutableCanvaskelas yang memegang AWT Canvas, dan blit(dan metode lainnya) dapat mengkloning yang mendasarinya Canvas, meneruskannya Display.setParent, lalu melakukan rendering dan mengembalikan yang baru Canvas(dalam pembungkus abadi Anda).


Pembaruan : Berikut adalah beberapa kode Java yang menunjukkan bagaimana saya akan melakukannya. (Saya akan menulis kode yang hampir sama di Scala, kecuali bahwa set yang tidak dapat diubah adalah bawaan dan beberapa untuk setiap loop dapat diganti dengan peta atau lipatan.) Saya membuat pemain yang bergerak dan menembakkan peluru, tapi saya tidak menambahkan musuh karena kodenya sudah lama. Saya membuat hampir semua hal copy-on-write - saya pikir ini adalah konsep yang paling penting.

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
sumber
2
Saya menambahkan beberapa kode Java - apakah itu membantu? Jika kodenya terlihat aneh, saya akan melihat beberapa contoh kecil dari kelas yang tidak dapat diubah, copy-on-write. Ini sepertinya penjelasan yang layak.
Daniel Lubarov
2
@ chaotic3quilibrium itu hanya pengidentifikasi normal. Saya kadang-kadang menggunakannya daripada argsjika kode mengabaikan argumen. Maaf atas kebingungan yang tidak perlu.
Daniel Lubarov
2
Jangan khawatir. Saya hanya berasumsi itu dan melanjutkan. Saya bermain dengan kode contoh Anda untuk kemarin kemarin. Saya pikir saya mengerti. Sekarang, saya bertanya-tanya apakah saya melewatkan sesuatu yang lain. Jumlah objek sementara sangat besar. Setiap centang menghasilkan bingkai yang menampilkan GameState. Dan untuk mencapai itu, GameState dari tick GameState yang sebelumnya melibatkan pembuatan sejumlah instance GameState, masing-masing dengan satu penyesuaian kecil dari GameState sebelumnya.
chaotic3quilibrium
3
Ya, itu sangat boros. Saya tidak berpikir GameStatesalinan akan menjadi mahal, meskipun beberapa dibuat setiap centang, karena mereka ~ 32 byte masing-masing. Tetapi menyalin ImmutableSets bisa mahal jika banyak peluru hidup pada saat yang sama. Kita bisa mengganti ImmutableSetdengan struktur pohon yang ingin scala.collection.immutable.TreeSetmengurangi masalah.
Daniel Lubarov
2
Dan ImmutableImagebahkan lebih buruk, karena menyalin raster besar ketika dimodifikasi. Ada beberapa hal yang bisa kita lakukan untuk mengurangi masalah itu juga, tapi saya pikir akan lebih praktis untuk hanya menulis kode rendering dengan gaya imperatif (bahkan programmer Haskell biasanya melakukannya).
Daniel Lubarov
4

Nah, Anda melumpuhkan upaya Anda dengan menggunakan LWJGL - tidak ada yang menentangnya, tetapi itu akan memaksakan idiom yang tidak berfungsi.

Namun, penelitian Anda sejalan dengan apa yang saya rekomendasikan. "Acara" didukung dengan baik dalam pemrograman fungsional melalui konsep-konsep seperti pemrograman reaktif fungsional atau pemrograman aliran data. Anda dapat mencoba Reactive , perpustakaan FRP untuk Scala, untuk melihat apakah itu dapat mengandung efek samping Anda.

Juga, keluarkan satu halaman dari Haskell: gunakan monad untuk merangkum / mengisolasi efek samping. Lihat negara dan IO monad.

Daniel C. Sobral
sumber
Tyvm atas balasan Anda. Saya tidak yakin bagaimana cara mendapatkan input keyboard / mouse dan output grafik / suara dari Reactive. Apakah ada di sana dan saya hanya melewatkannya? Mengenai referensi Anda untuk menggunakan monad - Saya baru saja belajar tentang mereka dan masih belum sepenuhnya memahami apa itu monad.
chaotic3quilibrium
3

Bagian non-deterministik (bagi saya) menangani aliran input pengguna ... menangani output (baik grafis dan suara).

Ya, IO adalah efek samping non-deterministik dan "semua tentang". Itu bukan masalah dalam bahasa fungsional yang tidak murni seperti Scala.

penanganan mengambil nilai acak untuk menentukan penembakan peluru alien

Anda dapat memperlakukan output generator nomor pseudorandom sebagai urutan tak terbatas ( Seqdalam Scala).

...

Di mana, khususnya, apakah Anda melihat perlunya sifat berubah-ubah? Jika saya dapat mengantisipasi, Anda mungkin menganggap sprite Anda memiliki posisi di ruang yang bervariasi dari waktu ke waktu. Mungkin bermanfaat untuk memikirkan "ritsleting" dalam konteks seperti itu: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php

Larry OBrien
sumber
Saya bahkan tidak tahu bagaimana menyusun kode awal sehingga pemrograman fungsional idiomatik. Setelah itu, saya tidak mengerti teknik yang benar (atau lebih disukai) untuk menambahkan kode "tidak murni". Saya sadar bahwa saya dapat menggunakan Scala sebagai "Java tanpa titik koma". Saya tidak ingin melakukan itu. Saya ingin belajar bagaimana FP mengatasi lingkungan dinamis yang sangat sederhana tanpa mengandalkan waktu atau nilai kebocoran yang dapat berubah-ubah. Apakah itu masuk akal?
chaotic3quilibrium