[Easy Rocket]タスク
処理のプリミティブな単位を汎用的なタスククラスとして抽象化して、アプリケーションレベル(シーン生成とかキャラクタの生成・動作の部分)のコードを出来るだけシンプルにしようという試み。
Atsushi's Homepage 〜 タスクとはを参考に過去にも実装を試みた記録があるが、そのときはうまくいかなかったらしい。が今回はかねがねイメージしていた理想に近い形でタスクを使用したサンプルコードを書くことが出来た。
Taskの基本機能として実装したものは以下。
- 木構造
- 毎フレーム呼ばれるrunメソッドとdrawメソッド
Taskを継承したGameTaskには位置や向きの情報を持たせた。ImageTaskには2D画像イメージを描画する機能がある。
今回書いてみたアプリケーションのコードはこれ。えぐぜりにゃ〜にまだなっていないもので、キャラが武器を持っていてそれを動かせるだけ。SPACEキーで武器が伸びる。コードについてはフルスクラッチしているのでもはやえぐぜりにゃ〜の改造……なのか?(いちおうこのコードのライセンスは修正BSDということにしておきます)。
require 'er' include ER::Key class Font < ER::FontTask image 'images/font.bmp' end class Bg < ER::ImageTask image 'images/bg.bmp' transparent false end class Ring < ER::ImageTask image 'images/ring.bmp' center true end class Hand < ER::ImageTask images ['images/hand0.bmp', 'images/hand1.bmp'] center true transform true end class Weapon < ER::GameTask attr_accessor :extend def after_create @l = 0 7.times { create_ring } create_hand end def run @l += (extend ? 12 : 0) + (0 - @l) / 4 @l = 0 if @l < 0 (rings + hands).each_with_index {|e, i| e.pos.x = @l * i} end end class Ship < ER::ImageTask images ['images/ship0.bmp', 'images/ship1.bmp'] center true def after_create create_weapon :pos => [64, 0, 0] end def run angle.z += 1 weapon.extend = key.press?(SPACE) pos.x += (key.press?(LEFT) ? -8 : 0) + (key.press?(RIGHT) ? 8 : 0) pos.y += (key.press?(UP) ? -8 : 0) + (key.press?(DOWN) ? 8 : 0) end end class Scene < ER::GameTask def after_create create_bg create_ship :pos => [100, 100, 0] [ ['', [0, 0, 0]], ['', [0, 16, 0]], ['MOVE=>[CURSOR]', [0, screen.h - 48, 0]], ['SHOT=>[SPACE]', [0, screen.h - 32, 0]], ['WAIT=>[W]', [0, screen.h - 16, 0]], ].each {|str, pos| create_font :str => str, :pos => pos } end def run fonts[0].str = "WAIT=%s" % (ER::sync? ? "ON" : "OFF") fonts[1].str = "%02dFPS" % ER::real_fps end end if $0 == __FILE__ ER::do_init 640, 480, 30.0 scene = Scene.new(:screen => ER::screen) ER::do_loop do scene._run scene._draw puts ER::real_fps end end
以下でポイントだけざっと解説。
画像タスク
BgはImageTaskを継承した画像タスククラス。2つのクラスメソッドが定義されている。 image "file name" と書くとその画像タスククラスが使用するイメージファイルを指定出来る。配列を渡すと複数の画像でアニメーションを行う。transparent false で抜け色処理をOFFに。デフォルトは抜け色処理がON。
このBgクラスを使用しているのはSceneクラス。after_createメソッドの中で実行されているcreate_bgによって生成され、sceneの子タスクとして登録される。
タスクにはrunメソッドとdwawメソッドがあり、これらが毎フレーム呼ばれることになる。例えば次のように書けば毎フレーム1度ずつZ軸に対して回転する。
def run angle.z += 1 end
タスク生成と木構造
Weaponは時機が持つ武器となるクラス。武器は複数のRingと一番先にあるHandで構成する。Weapon#after_create でRingとHandを子タスクとして生成している。
7.times { create_ring } create_hand
次のように書けば、RingとHandの間接構造をタスクの階層構造で実現することができる。
pa = self 7.times { pa = pa.create_ring } pa.create_hand
scene以下全てのタスクは木構造になっているので、例えばトップレベルからWeaponのワールド座標を取得する場合は次のようになる。
scene.ship.weapon.wpos
posはローカル座標、wposは親タスクの階層をたどって計算されるワールド座標を示すVector3Dクラス。
言語内DSL
クラスメソッドとして呼び出せるimage等のデフォルトオプションの定義やcreate_task_nameによる子タスクの生成のような記述方法はRailsに影響を受けている。ActiveRecordではhas_one, has_many 等のクラスメソッドでモデル間の関連を作るが、ER::Taskではいきなりcreate_task_nameメソッドでTaskNameを子タスクとして生成し同時にアクセサも作ってしまうので、こっちの方が凶悪かも。
これまでDSLって言われてもピンと来なかったんだけど、これがまさに言語内DSLってやつかな?
er.rb の実装
Ruby/SDL でシューティングのコードを参考にしつつ Ruby/SDL を使用している(ので上のコードは擬似コードじゃなくてちゃんと実行出来る)。
そしてevalを初めて使った。evalを使っていると頭がこんがらがってくる。Rubyのスコープの仕組みとか知らないことばかりで大変だった。が大変勉強になった。もっともっとRubyを勉強しないと。
速度的にはVector3Dクラスとそれを使った演算部分がネックだと思うので、その辺りを拡張ライブラリにしてしまえば問題ないと楽観視している。
EesyRocket ver 0.0
この間の勉強会で、適当に作ったソースコードを日記に張ったりすると、いい加減なコードでもそれがその人の実力と見なされて……という話があったけどそういうのぜんぜん気にしないので張っておく。長いけど、張っておかないとこういうのすぐなくしちゃうので。張っておくと後で検索出来て便利だし。
(補足: eval("#{name}s << task") の部分と parent= で #{name}s を変更をしていない部分が手抜き)
er.rb
require "optparse" require "sdl" class Numeric def to_rad Math::PI * self / 180 end end class Array def merge_hashs inject({}) {|opt, e| e.merge(opt) } end end module ER class Vector3D < Array def x ; self[0] ; end def y ; self[1] ; end def z ; self[2] ; end def x=v ; self[0] = v ; end def y=v ; self[1] = v ; end def z=v ; self[2] = v ; end def +dpos Vector3D[self[0] + dpos[0], self[1] + dpos[1], self[2] + dpos[2]] end end class Task def self.default_options *args @default_options = args.flatten class_eval args.flatten.map {|e| <<TEXT attr_accessor :#{e} def self.#{e} v ; @default_#{e}= v ; end def self.default_#{e} ; @default_#{e} ; end def self.default_#{e}_defined? ; defined? @default_#{e} ; end TEXT }.join end def self._default_options @default_options ||= [] @default_options + (defined?(superclass._default_options) ? superclass._default_options : []) end attr_reader :tasks, :boot_counter, :parent def initialize *args @parent = nil @tasks = [] @boot_counter = 0 before_create _create *args after_create end def before_create end def after_create end def _create *args self.class::_default_options.each do |e| eval "self.#{e} = self.class.default_#{e} if self.class.default_#{e}_defined?" end args.merge_hashs.each do |key, value| self.send "#{key}=", value end end def inspect "#{self.class} #{@boot_counter}" end def parent=v @parent.tasks.delete self if @parent @parent = v end def children @tasks.map {|e| [e, e.children] } end def _run run @tasks.each {|e| e._run } @boot_counter += 1 end def _draw draw @tasks.each {|e| e._draw } end def run end def draw end def method_missing(meth_name, *args) if /^create_(\w+)\z/.match(meth_name.to_s) && name = $1 klass = Kernel.const_get(name.split('_').map {|e| e.capitalize }.join) task = klass.new(*args) task.kind_of?(Task) || raise("`#{klass}.new.kind_of?(Task) => false'") task.parent = self @tasks << task unless eval("defined? #{name}s") self.class.class_eval("def #{name}s ; @#{name}s ||= [] ; end") self.class.class_eval("attr_accessor :#{name}") eval("self.send '#{name}=', task") else self.class.class_eval %Q|def #{name} ; raise "Use `#{name}s' method!" ; end| end eval("#{name}s << task") return task end super end end class GameTask < Task default_options :pos, :angle, :scale def screen=(v); @@screen = v; end def screen; @@screen; end def self.screen=(v); @@screen = v; end def self.screen; @@screen; end def pos ; @pos ; end def pos=args ; @pos = Vector3D[*args] ; end def angle ; @angle ; end def angle=args ; @angle = Vector3D[*args] ; end def scale ; @scale ; end def scale=args ; @scale = Vector3D[*args] ; end def _create *args options = args.merge_hashs %w(screen).each do |e| next unless options.has_key? e.to_sym self::send "#{e}=", options[e.to_sym] options.delete e.to_sym end super options self.pos ||= [0,0,0] self.angle ||= [0,0,0] self.scale ||= [1,1,1] end def wpos if parent if (r = parent.wangle.z) != 0 r = r * Math::PI / 180 x = pos.x * Math.cos(r) - pos.y * Math.sin(r) y = pos.x * Math.sin(r) + pos.y * Math.cos(r) parent.wpos + [x, y, pos.z] else parent.wpos + pos end else pos end end def wangle parent ? wangle = angle + parent.wangle : angle end end class ImageTask < GameTask default_options %w(transparent transform center image images) attr_reader :w, :h def cx ; center ? w / 2 : 0 ; end def cy ; center ? h / 2 : 0 ; end def initialize *args @transparent = true @transform = false @center = false super end def load_image(fname) image = SDL::Surface.load(fname) image.set_color_key(SDL::SRCCOLORKEY, image[0,0]) if transparent image.display_format end def images=v @images = v.to_a @image = @images.first @loaded_images = v.map {|image| load_image(image) } @w, @h = @loaded_images.first.w, @loaded_images.first.h @loaded_images end def image=v ; self.images = v ; end def draw return unless @loaded_images image = @loaded_images[boot_counter / 8 % @loaded_images.size] if !transform || (r = wangle.z) == 0 && scale.x == 1 SDL.blit_surface(image, 0, 0, 0, 0, screen, wpos.x - cx, wpos.y - cy) else SDL.transform_blit(image, screen, r, scale.x, scale.y, cx, cy, wpos.x, wpos.y, 0) end end end class FontTask < GameTask default_options :str @@bm_fonts= {} def self.image v class_eval "def image ; '#{v}' ; end" end def _create *args unless @@bm_fonts.has_key?(image) @@bm_fonts[image] = SDL::BMFont.open(image, SDL::BMFont::TRANSPARENT) end @font = @@bm_fonts[image] super end def image raise end def draw return if str.empty? @font.textout(screen, str, wpos.x, wpos.y) end end module Key include SDL::Key end def self.screen; @screen; end def self.real_fps; @real_fps; end def self.sync?; @do_sync; end def self.do_init screen_w, screen_h, fps @do_full = false ARGV.options do |opt| opt.on("-f") {|v| @do_full = true } opt.parse! end SDL.init(SDL::INIT_VIDEO | SDL::INIT_AUDIO) flag = SDL::HWSURFACE | SDL::DOUBLEBUF flag |= SDL::FULLSCREEN if @do_full @screen = SDL.set_video_mode(screen_w, screen_h, 0, flag) SDL::Mixer.open(22050 * 4) @do_sync = true @real_fps = 0 @fps = fps screen end def self.do_loop count = 0 tm_start = tm1 = SDL.get_ticks while true while event = SDL::Event2.poll case event when SDL::Event2::Quit exit when SDL::Event2::KeyDown case event.sym when SDL::Key::ESCAPE exit when SDL::Key::W @do_sync = !sync? end end end screen.fillRect(0, 0, screen.w, screen.h, [0, 0, 0]) SDL::Key.scan yield screen.flip # FPSを固定 if sync? tm2 = SDL.get_ticks diff = tm1 + (1000 / @fps) - tm2 SDL.delay(diff) if diff > 0 end tm1 = SDL.get_ticks # 実際のFSPを計算 count += 1 if count >= 30 count = 0 @real_fps = 30 * 1000 / (tm1 - tm_start) tm_start = tm1 end end end class GameTask def key SDL::Key end end end