kairo-gokko (10-1) Ruby/SDLを使ってDXOpalをエミュレートする(自分が必要な部分だけ適当に)



DXOpal はブラウザで実行できるため、SDL などのネイティブなライブラリが不要で、他の人に見てもらいやすいところが魅力的です。

しかし、実際開発してみるとブラウザで開いてから動き出すまでにそこそこ時間がかかったり、 デバッグまわりの勝手に慣れていなかったりでトライ&エラーの効率がなかなか上がりません。 なんとかできないかなと。


DXOpal は DXRuby に合わせて作られてますから、 開発中は DXRuby で作って、公開するときに DXOpal で動くように調整するというのはどうでしょうか(最終的には DXOpal で動かすのが目的です)。

……と一瞬考えましたが、ふだん使っているのが Ubuntu なので、DXRuby は使えない…… Wine で動かす試み ( DXRuby を Wine から使う - Qiita ) や dxruby-sdl などの互換ライブラリ ( DXRuby API互換のゲームライブラリを作るための頻出メソッド一覧 - Qiita )もあるようなのですが、いろいろあって今回は見送りました。


自分が今使いたい機能はといえば、 基本的な図形描画の機能(線、矩形、円 + 塗りつぶし)と、 マウス関連の機能(左ボタンの push イベントと x, y 座標取得)と、 後は効果音を出すくらいです。 アクションゲームやシューティングゲームを作っているわけではありませんから タイミングに関してシビアな要件はありません。

そこで思いついたのが、他のライブラリをバックエンドとして使いつつ API だけ DXOpal に合わせてお茶を濁す方法です。 やってみたら意外とすんなり動き、もうこれでいいや、となりました。


Ruby 2D に興味があったので最初は Ruby 2D を試しに使って書いてみて、 それでも十分動いたのでしばらく使っていました。 ただ、Ruby 2D は割と高水準よりの API になっていて ミスマッチな感じがしたのと、処理がちょっとだけもたつく感じだったので(これは Ruby 2D そのものの問題ではなく使い方が悪いせいです)、 Ruby/SDL で書き直しました。


できたものが下記の dxopal_sdl.rb です。

上述のように、自分が今必要とする要件がゆるく、 開発中だけそれっぽく動いてくれればそれでいいという代物なので、 かなり適当です(p_ とか特に……)。

上記のるびまの記事では FPS の管理に fpstimer.rb を使っていますが、そんなに高精度なものは必要なかったのでそこも適当。

※ この時点でのスナップショットということでベタッと貼りましたが、この後いくつか修正が入りました。 GitHub のリポジトリにあるものが最新版です。

(追記 2024-01-04) 事前コンパイル方式への変更に伴い kairo-gokko では dxopal_sdl.rb は使わなくなりました。一応 old/ ディレクトリ に移動して残しています。

require "sdl"

RUBY_ENGINE = "opal"

def require_remote(path)
  path.sub!(/\.rb/, "")
  require "./#{path}"
end

def p_(*args)
  DXOpal.p_(*args)
end

module DXOpal

  C_BLACK = [255,   0,   0,   0]
  C_WHITE = [255, 255, 255, 255]

  M_LBUTTON = :m_lbutton

  @@p_count = 0

  def self.p_(*args)
    args.each { |arg| p arg } if @@p_count < 10
    @@p_count += 1
  end

  module Window

    @@width = 640
    @@height = 480
    @@last_update = Time.now

    class << self

      def width=(w)
        @@width = w
      end

      def height=(h)
        @@height = h
      end

      def bgcolor=(color)
        @@bgcolor = color
      end

      def to_rgb_a(color)
        if color.size == 4
          [color[1..3], color[0]]
        else
          [color, 255]
        end
      end

      def load_resources
        SDL.init(SDL::INIT_EVERYTHING)

        SDL::Mixer.open

        @@screen = SDL.set_video_mode(
          @@width,
          @@height,
          16,
          SDL::SWSURFACE
        )

        yield
      end

      def handle_event(event)
        case event
        when SDL::Event::Quit
          exit
        when SDL::Event::MouseMotion
          Input.mouse_x = event.x
          Input.mouse_y = event.y
        when SDL::Event::MouseButtonDown
          if event.button == SDL::Mouse::BUTTON_LEFT
            DXOpal::Input.mouse_pushed_map_set(M_LBUTTON, true)
          end
        when SDL::Event::MouseButtonUp
          if event.button == SDL::Mouse::BUTTON_LEFT
            DXOpal::Input.mouse_pushed_map_set(M_LBUTTON, false)
          end
        end
      end

      def fill_bg
        draw_box_fill(0, 0, @@width, @@height, @@bgcolor)
      end

      def loop
        fps = 10
        interval_sec = 1 / fps.to_f

        while true
          while event = SDL::Event.poll
            handle_event(event)
          end

          if @@last_update + interval_sec < Time.now
            @@last_update = Time.now
            fill_bg
            yield
            @@screen.update_rect(0, 0, 0, 0)
          end
        end
      end

      def draw_line(x1, y1, x2, y2, color, z=0)
        rgb, alpha = to_rgb_a(color)
        antialias = false

        @@screen.draw_line(x1, y1, x2, y2, rgb, antialias, alpha)
      end

      def sdl_draw_box(x1, y1, x2, y2, color, fill)
        rgb, alpha = to_rgb_a(color)
        w = x2 - x1
        h = y2 - y1

        @@screen.draw_rect(x1, y1, w, h, rgb, fill, alpha)
      end

      def draw_box(x1, y1, x2, y2, color, z=0)
        sdl_draw_box(x1, y1, x2, y2, color, false)
      end

      def draw_box_fill(x1, y1, x2, y2, color, z=0)
        sdl_draw_box(x1, y1, x2, y2, color, true)
      end

      def sdl_draw_circle(x, y, r, color, fill)
        rgb, alpha = to_rgb_a(color)
        antialias = false

        @@screen.draw_circle(x, y, r, rgb, fill, antialias, alpha)
      end

      def draw_circle(x, y, r, color, z=0)
        sdl_draw_circle(x, y, r, color, false)
      end

      def draw_circle_fill(x, y, r, color, z=0)
        sdl_draw_circle(x, y, r, color, true)
      end
    end
  end

  class Sound
    @@map = {}

    class << self
      def register(name, *args, &block)
        path, _ = args
        @@map[name] = {
          path: path,
          sound: nil
        }
      end

      def [](name)
        sound = @@map[name][:sound]

        unless sound
          path = @@map[name][:path]
          wave = SDL::Mixer::Wave.load(path)
          sound = Sound.new(wave)
          @@map[name][:sound] = sound
        end

        sound
      end
    end

    def initialize(wave)
      @wave = wave
    end

    def play
      SDL::Mixer.play_channel(0, @wave, 0)
    end
  end

  module Input
    @@mouse_pushed_map = {}
    @@mouse_pushed_map[M_LBUTTON] = false

    @@mouse_x = 0
    @@mouse_y = 0

    class << self
      def mouse_pushed_map_set(code, val)
        @@mouse_pushed_map[code] = val
      end

      def mouse_x=(val)
        @@mouse_x = val
      end

      def mouse_y=(val)
        @@mouse_y = val
      end

      def mouse_x
        @@mouse_x
      end

      def mouse_y
        @@mouse_y
      end

      def mouse_push?(mouse_code)
        pushed = @@mouse_pushed_map[mouse_code]
        @@mouse_pushed_map[mouse_code] = nil
        pushed
      end
    end
  end

end

この dxopal_sdl.rb を使って開発を進め、 ある程度できたらブラウザ+DXOpal で動かしたいので、 次のように Kernel::Native の存在でブラウザかどうか判別して切り替えるようにしてみました。

def browser?
  Kernel.const_defined?(:Native)
end

if browser?
  require "dxopal"
else
  require "./dxopal_sdl"
end

追記 2023-08-06

qiita.com

事前コンパイルする方法もおすすめです。