2011年11月9日水曜日

Clojureでゲームプログラミングその1 実験編

0 コメント
移転先にも同じ記事ですがのせました。

はじめに

LispでGAMEつくろうかなと。 最初CommonLispでつくろうかなと思ったけども、現時点でCommonLispとClojure(+Java)を比較したときに 後者のほうが詳しいかなということで、まずはClojureでつくってみて、落ち着いたらCommonLispでもつくってみようかなと。

とりあえず、下記の流れで、画像を表示して動かすところまでチャレンジしてみる。

  • ウィンドウ表示
  • 画像表示
  • メインループの実現
  • 画像移動

もちろん前回インストールしたSLIMEとLeiningenを使って作業しますよ。 ちなみにClojureで「もの」を作るのは初めてなので、ホント手探りです。 しかもClojureでGameプログラミングの情報ってほとんどないんだよな。さて。

ウィンドウ表示

ClojureでのGUIはさっぱりわからんけども、とりあえずJavaのサンプルとかを頼りにウィンドウを表示してみる。

(import (javax.swing JFrame))
(def frame (JFrame. "Clojure SampleGame")) 
(doto frame
(.setSize 640 480)
  (.setVisible true))

でた。簡単すぎる!結構感動します。

次は画像を表示してみる。

画像表示

まずは、画像ファイルの読み込みだ。下記のコードをclojureで動かしてみる。

java.awt.image.BufferedImage bimage;
image = javax.imageio.ImageIO.read(new java.io.File("hoge.png"));

下記の画像を表示してみる。これは昔作ったゲームで使用したものでアニメーションパターンもはいってる。

画像ファイルはプロジェクト直下においてある(REPLを起動したディレクトリ)。 外部ファイルはプロジェクトルートからの相対パスでOKのようである。

(import (java.awt.image BufferedImage))
(import (javax.imageio ImageIO))
(import (java.io File))
(def image (ImageIO/read (File. "gai.png"))) 

とりあえずうまく読み込めたっぽいので、ウィンドウに表示してみる。

(import (java.awt Graphics))
(def graphics (.. frame (getGraphics)))
(doto graphics
  (.drawImage image 0 0 frame))

でた!

線もかけたよ。

(.. graphics (drawLine 0 0 640 480))

ところでこれまでの画像表示はタイトルバーにめり込んでしまっている。 これは描画命令の座標原点が、ウィンドウそのもの左上を原点としているためである。 これを回避するには、Graphics#translateを呼び出す。

位置調整

ずれの原因であるタイトルバーや枠お情報は、java.awt.Insetsとういクラスに格納されている。 これはJframe#getInsetsメソッドで取得でいるので、束縛しておく。

(def insets (.. frame getInsets)) 

確認。それっぽい値が入っている。

user> insets
#<Insets java.awt.Insets[top=24,left=1,bottom=5,right=1]>

では、このinsetsを使って描画用原点をずらしてみる。

(.. frame (setVisible true))
(.. graphics (translate (.. insets left) (.. insets top)))
(.. graphics (clearRect 0 0 640 480))
(.. graphics (drawImage image 0 0 frame))

これで左上原点が、ずれていい具合に表示された。

insentsを利用したついでに説明。 実はウィンドウサイズで640x480を指定しているけれども、 タイトルバーや枠のサイズがあるため、描画領域は640x480よりもちょっと小さい。 なので、例えば640x480ちょうどのサイズの画像を表示しようとしても少し切れてしまったりする。 ということで、insentsを利用して描画領域が純粋に640x480似なるように調整する。

(doto frame
    (.setSize (+ 640 (.. insets left) (.. insets right)) (+ 480 (.. insets top) (.. insets bottom)))
    (.setVisible true))

以上で描画領域の細かい調整が完了した。

画像の一部を表示したい

ところで、現状だとアニメパターンがすべて表示されていてみっともないので 一部だけを表示したい。 この要件を満たすには、Graphics#DrawImageで下記のように引数を指定すれば良い。

(.drawImage
    image   ;; 描画画像
    0 0     ;; 転送先の左上座標
    32 32   ;; 転送先の右下座標
    0 0     ;; 画像元の左上座標
    32 32   ;; 画像元の右下座標
    frame)  ;; 描画対象
(import java.awt.Color)
(def clear-color (Color. 0 0 127))

(let [g (.. frame (getGraphics))]
  (doto g
    (.translate (.. insets left) (.. insets top))
    (.setColor clear-color)
    (.fillRect 0 0 640 480)
    (.drawImage image
                0 0 32 32
                0 0 32 32
                frame)
    (.dispose)))

なんども描画してると、前の画像が残って確認しづらいので 描画前にクリアカラーで塗りつぶしている。 ついでにグラフィックオブジェクトもその都度破棄するようにした。

つぎはゲームのかなめ、メインループを実現してみる。

メインループの実現

メインループの実現方法にはにはいろいろあけれども とりあえず動かすことが目的なので、 実装が簡単そうなjava.util.TimerTaskを使用してみる。

Clojureで継承が必要なJavaクラスを使うには、proxyを使う。 以下のようにTimerTaskを継承したクラスをつくる。

(import (java.util Timer TimerTask))
(import (java.util TimerTask))
(def mainloop
  (proxy [TimerTask] []
    (run []
      (println "呼びだされた"))
    )) 

テストしてみる。

user> (.. mainloop run) 
呼びだされた
nil

うまくいっているようである。

続いてTimeクラスにmainloopを渡してみる。これがはまった。

user> (.. (Timer.) schedule mainloop 0 500)

Malformed member expression
  [Thrown class java.lang.IllegalArgumentException]

Restarts:
 0: [QUIT] Quit to the SLIME top level

Backtrace:
  0: clojure.lang.Compiler$HostExpr$Parser.parse(Compiler.java:825)
  1: clojure.lang.Compiler.analyzeSeq(Compiler.java:5369)

「Malformed member expression」の原因がわからなくてかなりはまった。 ぐぐった結果、javaのlong型を引数として渡すときは、long関数を呼び出す必要があることがわかった。

user> (.. (Timer.) schedule mainloop (long 0) (long 500))
No matching field found: schedule for class java.util.Timer
  [Thrown class java.lang.IllegalArgumentException]

Restarts:
 0: [QUIT] Quit to the SLIME top level

Backtrace:
  0: clojure.lang.Reflector.getInstanceField(Reflector.java:245)
  1: clojure.lang.Reflector.invokeNoArgInstanceMember(Reflector.java:267)

とここでまたエラー。でまたぐぐった結果、メソッドの呼び出し方が間違っていたorz。 scheduleをカッコでくくらないと駄目らしい。

user> (.. (Timer.) (schedule mainloop (long 0) (long 500)))
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
呼びだされた
nil

ということでやっとTimerクラスにTimerタスクで作ったメインループを渡すことができた。ふぅ。 次は画像を動かしてみる。

画像の移動

いよいよ画像に魂を与える。その為には状態を管理しなければならない。座標ですね。 Clojureはデフォルトでは値を更新できない。これを変更するためには特別な定義が必要。 スレッドを使う予定は今のところ無いので、扱いの簡単そうなatomを使用してみる。

;; プレイヤー定義
(def player (atom {:pos [0 0]}))

こんな感じで参照できる。

user> (@player :pos)
[0 0]
user> 

x座標は配列の0番目

user> (nth (@player :pos) 0) 
0

y座標は配列の1番目

user> (nth (@player :pos) 1) 
0

playerの座標を更新してみる。

user> player
#<Atom@2a134eca: {:pos [0 0]}>

;; 変更
user> (swap! player assoc :pos [0 1])
{:pos [0 1]}

;; たしかに更新された
user> player
#<Atom@2a134eca: {:pos [0 1]}>

x座標を更新させる

user> (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1])
{:pos [1 1]}
user> (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1])
{:pos [2 1]}
user> (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1])
{:pos [3 1]}
user> (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1])
{:pos [4 1]}

ではここまでの移動処理を組み込む。

(def mainloop
  (proxy [TimerTask] []
    (run []
      (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1]) ;; 座標更新
      (if (< 640 (nth (@player :pos) 0))
             (swap! player assoc :pos [0 0]))

      (let [g (.. frame (getGraphics))
            player-x (nth (@player :pos) 0)
            player-y (nth (@player :pos) 1)]
        
        (doto g
          (.translate (.. insets left) (.. insets top))
          (.setColor clear-color)
          (.fillRect 0 0 640 480)
          (.drawImage image
                      player-x player-y
                      (+ player-x 32) (+ player-y 32)
                      0 0 32 32
                      frame)
          (.dispose))))))

定義したメインループを20ms間隔で呼び出す。

(.. (Timer.) (schedule mainloop (long 0) (long 20)))

画面がちらつきというか、画像が点滅していて話にならない。次はこのチラツキを抑えるために java.awt.image.BufferStrategyを使ってみる。

ちらつき防止対策

下記のようにJFrame#setIgnoreRepaint, JFrame#createBufferStrategyを呼び出しバッファの準備をする。 この時注意すべきは、JFrame#createBufferStrategyはJFrame#setVisibleのあとに呼び出さなければならないこと。

(doto frame
    (.setSize (+ 640 (.. insets left) (.. insets right)) (+ 480 (.. insets top) (.. insets bottom)))
    (.setVisible true)
    (.setIgnoreRepaint true) ;; ウィンドウの再描画を無効に(BufferStrategyを使うので)
    (.createBufferStrategy 2) ;;  setVisibleメソッドのあとで呼ばないと実行時エラーになる
    )
;; バッファ作成
(def buffer (.. frame (getBufferStrategy)))

bufferを使って以下のように呼び出し。TimerTaskクラスオブジェクトは都度生成できるように関数化しておいた。

(defn create-mainloop
  []
  (proxy [TimerTask] []
    (run []
      (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1]) ;; 座標更新
      (if (< 640 (nth (@player :pos) 0))
             (swap! player assoc :pos [0 0]))

      (if (not (.. buffer (contentsLost)))
        (let [g (.. buffer (getDrawGraphics))
              player-x (nth (@player :pos) 0)
              player-y (nth (@player :pos) 1)]
          (doto g
            (.translate (.. insets left) (.. insets top))
            (.setColor clear-color)
            (.fillRect 0 0 640 480)
            (.translate (.. insets left) (.. insets top))
            (.drawImage image
                        player-x player-y
                        (+ player-x 32) (+ player-y 32)
                        0 0 32 32
                        frame)
            (.dispose))
          (.. buffer (show))
          )))))

最終的なコード。

(ns hello-cube.core)

(import (javax.swing JFrame))
(import (java.util Timer TimerTask))
(import (java.awt Graphics Color))
(import (java.awt.image BufferedImage))
(import (javax.imageio ImageIO))
(import (java.io File))

(def clear-color (Color. 0 0 127))
(def frame (JFrame. "Clojure Sample Game")) 
(def image (ImageIO/read (File. "gai.png"))) ;; 画像読み込み
(def player (atom {:pos [0 0]}))

;; 枠を考慮してサイズ指定
(doto frame
    (.setVisible true)
    (.setIgnoreRepaint true) ;; ウィンドウの再描画を無効に(BufferStrategyを使うので)
    (.createBufferStrategy 2) ;;  setVisibleメソッドのあとで呼ばないと実行時エラーになる
    )

;; バッファ作成
(def buffer (.. frame (getBufferStrategy)))
(def insets (.. frame getInsets))  ;; ウィンドウを表示してから出ないと値が入らない。

(doto frame
    (.setSize (+ 640 (.. insets left) (.. insets right)) (+ 480 (.. insets top) (.. insets bottom))))


(defn create-mainloop
  []
  (proxy [TimerTask] []
    (run []
      (swap! player assoc :pos [(+ 1 (nth (@player :pos) 0)) 1]) ;; 座標更新
      (if (< 640 (nth (@player :pos) 0))
             (swap! player assoc :pos [0 0]))

      (if (not (.. buffer (contentsLost)))
        (let [g (.. buffer (getDrawGraphics))
              player-x (nth (@player :pos) 0)
              player-y (nth (@player :pos) 1)]
          (doto g
            (.translate (.. insets left) (.. insets top))
            (.setColor clear-color)
            (.fillRect 0 0 640 480)
            (.translate (.. insets left) (.. insets top))
            (.drawImage image
                        player-x player-y
                        (+ player-x 32) (+ player-y 32)
                        0 0 32 32
                        frame)
            (.dispose))
          (.. buffer (show))
          )))))

(def timer (Timer.))
(.. timer (schedule (create-mainloop) (long 0) (long 20)))

所感

ほとんどJavaのメソッドしか使ってないけどもSLIMEの良さは体感できた。 コードが即時反映されて画像が動くのは楽しい。 理想はSLIME上からGameObjectをリアルタイムに操作することだけど、これを実現するにはatomでは無理かも。 あとはスレッドとSLIMEの関係がよくわかってない。TimeTaskを使いにくく感じたのでメインループは別の方法で実現したい。

次回はもうちょっとコードのリファクタリングを施しつつ、 アニメーションとキーボード操作をできるようにしてみる。

参考書籍

Date: 2011-11-12 10:11:07 JST