複素数を扱う

DXRuby Advent Calendar 201520日目の記事です。
19日目はあおいたくさんの[Ruby][DXRuby] ピザ焼けなかったので野良メイドを拾って育てるゲーム作りました - あおたくノートでした。

「野メイド」はGUIフレームワークSpriteUIを使ったテキストSLGということで、動かした感じ、基本的な部分は非常によくできている印象を受ける。作りとしてはSpriteUIのベースに薄いテキストSLGツール的なものをかぶせて、ゲームロジック自体はscriptsフォルダ内のboot.rbにまとめて書かれているのが興味深い。これはこれでテキストSLG作成キットとかでリリースしてもいいんじゃね?と思うと同時にSpriteUIのデキの良さに感心する次第である。なお、ゲームそのものは現時点ではゲーム的な要素があまりなく、デモみたいなものとなっている。特にイチャイチャライフは未実装なのでそれが欲しい人は自分で実装(そして公開)すること。
それにしても今年はミニゲームが多い。司エンジン製、SpriteUI製、WS製、それらを使わないものもあり、片手で数えられなくなっている。えらいこっちゃである。

さて、20日目の記事はDXRuby作者がお届けするということで、今年の傾向を完全に無視して、ちょっとしたテクニックとサンプルコードを紹介する。題して「複素数を扱う」。
DXRubyはメンテナンスされているRubyをサポートするような方針でいるので、現時点ではRuby2.0、2.1、2.2を対象としているが、もうちょっとしたらRuby2.3がリリースされる。そしたら近いうちにRuby2.0のサポートが終わり、Ruby2.1が最低ラインになる。なのでRuby2.1以降を対象としたネタがあってもよい。
Ruby2.1では複素数リテラルが導入されて、「1 + 2i」などと言う記法でComplexオブジェクトを生成できるようになった(リテラル表記せずComplex(1, 2)と書く使い方は以前からできた)。
今回はこの複素数を使ってらくちんにゲームを作るよ、という話(ゲームを作るとは言っていない)。

複素数とは

そもそも数学的な難しいことはよくわかっていないのだが、複素数と言われるのは「1 + 2i」のような表現をされる値で、ここでいう1を実部、2を虚部と呼ぶ。iは虚数単位で、2乗したら-1になる不思議な値である。1は普通の値なのだが、2のほうはiがかけられているので1との計算を行うことができず、わけて表記される。Complexオブジェクトというのは実部と虚部を持った、つまり2つの値をまとめて保持するオブジェクトである。
通常の値は小さい値から大きい値までの1次元軸上に表現される。複素数はもうひとつの軸を追加したようなものであり、虚部がどれだけ変化しても実際の値(実部)には影響しない。例えるなら、我々が住む3次元空間に対して4つ目の軸が存在していたとして、その軸上でいくら動いたとしても、xyzの3軸レベルでは同じ位置を表す、という感じだ。4次元の世界では3次元の住人から見た同じ位置に別の物体が重なって存在できる。

正弦波と複素数

以下の図は単純に正弦波を描画したものである。

横軸が時間、縦軸が波の大きさで、なんというかそのままである。
もうひとつ図を用意した。

この円を半径1とすれば、中心点から角度θで円周まで線を引いたときに、その交点の縦位置はsin(θ)となる。この図はDXRubyの座標系なのでy軸は下向きにプラスであるから、θが増えると時計回りに線が回転する。
横軸を実部、縦軸を虚部と考え、複素数で平面上の点を表現する場合、この座標系を複素平面と呼ぶ。θを一定速度で増やして線をぐるぐる回したとき、虚部yの値はy = a * sin(ωt)となり、横軸をtとしたグラフにyをマッピングすると、上の図のように正弦波の絵になる。aは波の最大高さで、ωは2πfつまり角周波数(1秒あたりどんだけ回転するかのラジアン表現)である。また、実部xはx = a * cos(ωt)となり、上のグラフは横向きだが、これを下向きにマッピングしてやると余弦波のグラフになる。
ともあれ、複素平面上での円運動に対して実部・虚部のどちらかのみを抽出すると綺麗な波になり、この波形は音であり、電気の交流であり、海の波であり、電波であるということで、現実に即していながら、もうひとつの軸の値はどこいったの?という思いもあって奇妙な感覚を覚える。

Rubyにおける複素数の扱い方

Complexクラスを使う。Ruby2.1以降はリテラルでComplexオブジェクトを生成することができるが、変数から生成しようとすると変数名とiが繋がってしまうので、リテラルとの積を取るか、従来の方法で生成することになる。

p 1 + 2i          #=>(1+2i)
p 1 + 2i + 2 + 3i #=>(3+5i)
p (1 + 2i) * 3    #=>(3+6i)

x = 1
y = 2
p x + y * 1i #=>(1+2i)

p (1 + 2i) * (2 + 3i) #=>(-4+7i)

iを2回かけると-1になるので最後の計算はこうなる。1 * 2 + 1 * 3i + 2 * 2i + 2i * 3i、ということである。また、従来の方法での生成はこんな感じ。

p Complex(1, 2) #=>(1+2i)
p Complex.rect(1, 2) #=>(1+2i)

Complexはクラスだが、このクラスにはなぜかnewが無く、Kernel.#ComplexメソッドかComplex.rectメソッドを使う。

複素数を使った座標計算

2つの値を同時に保持できるし、同時に計算できるので、ベクトルと似たような使い方ができる。足し算、引き算、スカラー値での乗算、除算をする限りにおいて問題は発生しない。

require 'dxruby'

xy = 0 + 0i # 座標
d = 1 + 1i  # 増分
s = Sprite.new(0, 0, Image.new(100, 100, C_WHITE))

Window.loop do
  xy += d # 座標に増分を足す
  s.x, s.y = xy.real, xy.imag # 実部、虚部を取り出してx, yにセット
  s.draw
  exit if Input.key_push?(K_ESCAPE)
end

このコードはトウフが左上から右下に向かって移動する。座標の計算が足し算1回で済んでいるところがミソで、例えば1 + 1iじゃなく1だけ足せば右に動くし、1iだけ足せば下に動くようになる。
とはいえこれでは何も嬉しくないし、上のほうで説明した正弦波と何の関係も無い。
本題はここから。

極座標表現

あれ、極座標って最近見たぞ?と思った人は多いだろう。まさかいないとは思うがピンとこない人は土井ヴぃ氏に泣いて謝りながら7日目の記事である画像素材を用意せずにいきなり始めるゲーム開発~PIZZA COOKER解説~ - いつクリはてブロを見てくること。
複素数には実部と虚部で表現する直交形式表現と、絶対値と偏角で表現する極形式表現がある。Ruby2.1の複素数リテラルは直交形式表現のみ対応だが、Complex.polarメソッドを使うことで極形式の値からComplexオブジェクトを生成することができる。直交形式では実部x、虚部yとすると、c = x + yi、極形式では絶対値rと偏角θで、c = r * (cos(θ) + sin(θ)i)ということになる。ComplexオブジェクトはComplex#realとComplex#imagで実部、虚部を取り出すことができ、引数無しのpolarメソッド呼び出しで極形式の値を取得することもできる。つまりは直交形式と極形式を自由に変換することが可能である。ということだ。
これを使うと座標の回転などを三角関数無しで扱うことができる。誤解の無いように書いておくとComplexの中では三角関数は使われていて、自分で書く必要がない、という話。

三角関数を使わないコーディング

例えば、いまさらだが上のほうの円を描画したコードは以下のようになっている。

require 'dxruby'

Window.loop do
  # 軸
  Window.draw_line(0, 240, 639, 240, C_WHITE)
  Window.draw_line(320, 0, 320, 479, C_WHITE)

  # 円
  Window.draw_circle(320, 240, 200, C_WHITE)

  # 線
  c = Complex.polar(200, Math::PI / 4) # 極座標表現によるComplex生成
  Window.draw_line(320, 240, 320 + c.real, 240 + c.imag, C_WHITE)

  # θ
  Window.draw_font(340, 240, "θ", Font.default)

  # 他
  Window.draw_font(296, 450, "虚軸", Font.default)
  Window.draw_font(580, 210, "実軸", Font.default)

  exit if Input.key_push?(K_ESCAPE)
end

Complex.polarを使って絶対値200、偏角45度の座標を持つComplexオブジェクトを作ってみた。ここから実部、虚部を取り出して線の描画に使う。無論、この角度を毎フレーム増やしてやれば線はぐるぐる回る。

三角関数を使わないコーディングその2

ちょっとしたデモを。
マウスカーソルに向けて弾を発射するのに三角関数を使うとatan2とかsinとかcosとか使ってガリガリ計算することになるが、Complexを使うことでこれらの処理を全て駆逐することができる。でもラジアンと360度系の変換だけは残る。こればっかりはしょうがない。書いてて思ったが全ての角度表現をラジアン指定に変更するオプションとかあると嬉しい人いる?

require 'dxruby'

# 砲台
class Cannon < Sprite
  @@image = Image.new(40, 40).box_fill(0, 0, 30, 39, C_YELLOW)
                             .box_fill(30, 10, 39, 29, C_YELLOW)
  def initialize(x, y)
    super(x, y)
    self.image = @@image
    self.offset_sync = true
    self.z = 1
  end

  def update
    # マウス位置と自身の座標の差分を取り、偏角を取得して360度系に変換
    ang = Complex(Input.mouse_x - self.x, Input.mouse_y - self.y).polar[1]
    self.angle = ang / Math::PI * 180 # 砲台をマウスのほうに向ける

    if Input.mouse_push?(M_LBUTTON)
      b = Ball.new(self.x, self.y)
      b.v = Complex.polar(6, ang) # ボールの初期移動量
      $balls << b
    end
  end
end

# ボール
class Ball < Sprite
  attr_accessor :v # 移動量
  @@image = Image.new(20, 20).circle_fill(10, 10, 10, C_WHITE)

  def initialize(x, y)
    super(x, y)
    @v = 0
    self.image = @@image
    self.offset_sync = true
  end

  def update
    # 移動
    self.x += @v.real
    self.y += @v.imag

    # 消える処理
    self.vanish if self.y > 500

    # 重力加速
    @v += 0.1i # Complexリテラル♪
  end
end

$balls = []
cannon = Cannon.new(300, 220)

Window.loop do
  Sprite.update([cannon, $balls])
  Sprite.draw([cannon, $balls])
  Sprite.clean($balls)
  exit if Input.key_push?(K_ESCAPE)
end

動かすとこんな感じ。

微妙に補足しておくと、Complex#polarが返す値は[絶対値, 偏角]という配列である。あと、Spriteはoffset_syncをtrueにして画像の中心を座標と一致させるようにしている。三角関数を扱うような処理(対象までの角度を取得するとか座標を回転させたりとか)をする場合はこのほうが計算が簡単になる。

正弦波の描画

上のほうの正弦波を描画するコードは以下。ざっくり適当に作ったのでちょっとアレだが。

require 'dxruby'

old_y = 0
image = Image.new(640, 480).line(0, 240, 639, 240, C_WHITE)
                           .line(20, 0, 20, 479, C_WHITE)
(-1..640).each do |angle|
  y = Complex.polar(100, Math::PI * (angle-20) / 180).imag # 極座標によるComplex生成と虚数取得
  # y = Math.sin(Math::PI * (angle-20) / 180) * 100        # これと同じ結果
  image.line(angle - 1, old_y + 240, angle, y + 240, C_WHITE)
  old_y = y
end

Window.loop do
  Window.draw(0, 0, image)
  exit if Input.key_push?(K_ESCAPE)
end

おまけ

Complexは複素平面上の点の平行移動、スケーリング、回転が簡単に可能なので、計算式を組み立てることさえできれば2D限定で行列の代わりになるのではなかろうか、という発想で、計算機クラスを作ってみた。

# 計算式を保持、接続して後でまとめて計算できる計算機クラス
class Calc
  attr_accessor :prcs # 計算式を保持する配列

  # 計算式を繋げるためのProc
  @@base = ->calc{->prc{->x{calc[prc[x]]}}}

  # initializeの引数は引数を1つ取って計算結果を返すProc
  def initialize(prc)
    @calc_prc = nil
    @prcs = Array(prc)
  end

  # 計算を実行する
  def [](v)
    # 初回実行時に計算用Procを構築する
    unless @calc_prc
      @calc_prc = @@base[@prcs[0]][->x{x}] # ->x{x}はターミネータ
      @prcs[1..-1].each do |prc|
        @calc_prc = @@base[prc][@calc_prc]
      end
    end

    # 計算実行
    @calc_prc[v]
  end
  alias_method :call, :[]

  # 計算式を接続して新たなCalcを作る
  def +(v)
    Calc.new(@prcs + v.prcs)
  end
end

if __FILE__ == $0
  hoge = Calc.new(->x{x+1}) # +1する計算機
  p hoge[5] #=>6

  fuga = Calc.new(->x{x*5}) # *5する計算機
  p fuga[5] #=>25

  piyo = hoge + fuga # +1してから*5する計算機
  p piyo[5] #=>30

  # 複素数も扱える
  hoge = Calc.new(->x{x+1+1i}) # +1+1iする計算機
  p hoge[5+6i] #=>(6+7i)

  fuga = Calc.new(->x{x*5}) # *5する計算機
  p fuga[5+6i] #=>(25+30i)

  piyo = hoge + fuga # +1+1iしてから*5する計算機
  p piyo[5+6i] #=>(30+35i)

  # 回転する計算機
  # クロージャにすれば計算に使う値を後から変更できる
  # ここではサンプルのためにCalcを3つ繋げているが1つで作ってもよい
  center = 100+100i
  angle = 90
  hoge = Calc.new(->x{x - center})
  fuga = Calc.new(->x{tmp = x.polar;Complex.polar(tmp[0], tmp[1] + Math::PI / 180 * angle)})
  piyo = Calc.new(->x{x + center})
  p (hoge+fuga+piyo)[150+150i] #=>(50.0+150.0i) # (100,100)を中心に右90度回転
end

まあ、Proc遊びである。このようなものを作ってみたのでこれで何か作れないかなーと思ったのだが、おまけが大きくなりすぎるのもアレなのでそのうち別記事でまとめる。かもしれない。

おしまい

次はあおいたくさんの「ChipmunkとTiledとDXRubyと私」。ChipmunkとDXRubyというのは面白いテーマ。TiledはマップエディタのTiledだと思うがどのように組み合わせてくるのかしらん。大いに期待。