kairo-gokko (15-2) 通電 6



さて、アイデアはアイデアとして、 問題はどうやって経路を辿るかです。

最初は データの整形 3 のときのように辿っていけばええんやろ?   と素朴にやってみてたんですがうまくいかず……。

問題に向き合って正攻法でやらないとダメっぽい、 真面目に経路探索しないといけないようだぞ、 という雰囲気が漂ってきたので、観念して調べることにしました。

こういう場合は気持ちを切り替えるのが吉。 通電判定処理の部分がこのアプリケーションの心臓部になるだろうと漠然と考えていたこともあり、重要度に見合った手間だと思うことにしました。 急がば廻れでここをちゃんと作っとけばこの後安心して進められるだろうと。

それに、こういう状況は勉強のチャンスです。今まさに自分が作っているもののために必要なためモチベーションが高く(必要性についてすでに理解していて切実)、調査と学習の効率がよくなります。 グラフのアルゴリズムや経路探索についてまともに考えたり使ったりしたことがなかったので良い機会でした。


はい。それで、ダイクストラ法を使うことにしました。

ダイクストラ法そのものについてはネット上にも多くの解説コンテンツがありますので 説明は省きます。 私が下手に説明するよりもそちらを見てもらった方がよいと思います。たとえば次のもの。

他にも、探すと Ruby による実装もたくさん見つかります。


できたものがこれです。300行ちょい。

https://github.com/sonota88/kairo-gokko/blob/step15/tuden.rb

さきほど書いた通り基本はダイクストラ法ですが、 いくつか考慮すべき点があります。

たとえば……と説明しだすと話が長くなるので、

とにかくこれで動くようになったんや、

ということにして 細かい話は飛ばして次に進みます(気が向いたらあとで別途書くかもしれません)。

(※ ネタバレっぽくなりますが、この部分の処理は最終的に不要になりました。 先に読みたい方は ステップ36 へどうぞ。 )

プロトタイプのときに書きなぐった汚いコードを 少し手直しした程度なのでいろいろ怪しいですが、 これ以上のリファクタリング・最適化は後まわしにします。 先に進みたい。

備考

以下、いくつか備考です。

物理回路との違い

今の時点で物理回路と挙動が違うと分かっているところがあります。

f:id:sonota88:20200305061500p:plain

こういう回路の場合、 A と B 間で電位差がない場合は A-B のエッジには電流が流れないそうです(知りませんでした)。

参考: ホイートストンブリッジを復習 - 始める電子回路

今回作った通電判定器ではこの場合も「通電する」と判定してしまいます。 そもそも電位差という概念がないんですよね。 この点に関してはすぐに解決策が思い浮かばないので 「この世界ではそういうルールなのだ」 ということにして目をつぶります。

プロトタイプのとき

今回作っているのは2周目です。

プロトタイプのときは自動テストなしでゴリゴリ書いてました。 いくつかのケースを手で動かして確認して、 あとはランダムな回路を生成して通電判定器にかけ、 graphviz で可視化して目で見て確認して、 だいたい良さそうな感じだと思えるようなところまで持っていく。

たとえば次の図はノード数=10 でランダムに生成したもので、

  • 行き止まりになっている 4-7 のエッジは通電しない
    • 通電 1 で挙げていたパターンが解決されている
  • 3-10 のエッジは、スイッチはすべて ON(--)になっているが、 10-810-6 のエッジ上にあるスイッチが OFF(x, xx)なので通電しない

となっていることが確認できます。

f:id:sonota88:20200305063659p:plain

……ということをやっていたんですが、ここはめんどくさい(ちょっといじるとすぐ壊れる)ので今回はさすがに自動テストを書きました。


ちなみに、プロトタイプを作っていたときは ここだけ(やり方を考えるところから実装して十分満足できるまで)で 3週間くらいかかりました。 今考えると不必要な最適化をしたりしてたので 1週間くらいは無駄だった気がします。 まあ、趣味で楽しんでやってたので、よいのですが。 次からは多少は要領よくやれたり勘が働くようになるでしょう。 いい経験になりました。

競プロ勢の人たちとか、こういうの朝飯前なんでしょうねー。

ダイクストラ法じゃなくてもよかった?

さっき思いついた的なことのメモ。 まだ試してないので結論は出てないです。

  • 知りたいのは到達可能性と到達できる場合の経路
    • 「最短」経路じゃなくていい
  • エッジの重みをすべて同じにしている
  • ひょっとしてもっと素朴な幅優先探索でもよかった?

ただし、

  • 最短経路の方が人間の直観には近そう
  • 冗長な経路だと [+] [-] を目指す経路同士がぶつかりやすくなりそう

あとは、処理コストと「それっぽい動作になるかどうか」との兼ね合いでしょうか。



kairo-gokko (15-1) 通電 5



前回はエッジ 4本の場合の中でも限定的な配線パターンを対象に、それだけをなんとかする通電判定処理を作ってみました。 しかし、あのやり方ではすぐに行き詰まるでしょう。

エッジが 5本でも 100本でも解決できる汎用的な方法はないのでしょうか。


ふわふわしたことを言っていると話が進まないので、 適当な具体例を挙げて考えます。

f:id:sonota88:20200306051812p:plain

×印を付けたエッジではスイッチが OFF になっています。 また、線が交わっている箇所はすべてノードとします。

描いてみたはいいけど、どうすんのこれ。


えっと……これをまるごと扱おうとすると手に負えないので、 ぐちゃぐちゃした部分の真ん中あたりにある色を付けたエッジに着目します。

f:id:sonota88:20200306052046p:plain

たとえばこのエッジが通電するか、どうすれば分かるのか……?


こう考えてみました。

(1) まず着目しているエッジの片方の端点から出発してプラス極を目指す。 スイッチが OFF になっているエッジは通れないので迂回する。 プラス極まで辿りつければ OK。

f:id:sonota88:20200306052213p:plain

(2) 同じように、 着目しているエッジのもう片方の端点から出発してマイナス極を目指す。 マイナス極まで辿りつければ OK。

f:id:sonota88:20200306052333p:plain

(3) (1)(2) が両方とも OK なら着目しているエッジは通電すると判定する。

f:id:sonota88:20200306052538p:plain

緑の点線の経路が通れるのであれば、 端から端までずっとつながっているのですから、 たしかに着目しているエッジにも通電するだろう、 物理的にこの回路を作っても通電しそうだ、 と思えます。 直観としてはおかしくはない。

そうして1本のエッジについて判定できるようになったら、 あとはすべてのエッジについて繰り返せばよいのでは。

# こういうイメージ。この形に持っていきたい。

def edge_tuden_hantei(edge)
  is_tuden_plus  = ... # ここをどうにかして作る
  is_tuden_minus = ... # ここをどうにかして作る
  is_tuden_plus && is_tuden_minus
end

edges.each { |edge|
  is_tuden = edge_tuden_hantei(edge)
  edge.update_state(is_tuden)
}

通電しないパターンも挙げておきます。

たとえば、次の図のエッジ e1 やエッジ e2 にあるスイッチが OFF になっていると、プラス極に辿りつくことができず通電しません。

f:id:sonota88:20200306052834p:plain


ふーむ……ほんとにこのアイデアでなんとかなるんでしょうか?



kairo-gokko (14) 通電 4



前回はエッジ1本と複数のスイッチのケースまで対応しました。

今回はもう少し複雑なパターンとして、途中で分岐してスイッチが並列になっている回路の通電判定を考えます。

f:id:sonota88:20200301041056p:plain

「並列つなぎ」というやつですね。 小学校の理科でやるレベルのものですから、さすがにこのくらいはなんとかしたい。


少し整理して、エッジとスイッチに e1e4, s1, s2 と名前を付けました。

f:id:sonota88:20200301052456p:plain

まずは「このパターンになっているか?」を調べて、 次に通電判定を行う、という方針で進めましょう。

(1) パターンの認識

前回とはエッジの本数が違いますので、まずはそれで分岐させましょうか。

class Circuit
  # ...

  def update_4_edges
    # これから書く
  end

  def update_edges
    case @edges.size
    when 1
      is_tuden = Tuden.tuden?(@switches)
      @edges[0].update(is_tuden)
    when 4
      update_4_edges()
    else
      raise "not yet implemented"
    end
  end

  # ...

エッジ 1本の場合は前回と同じロジックをそのまま使います。 4本の場合はこれから作ります。 それ以外の場合は、未対応ということにしてとりあえず例外を投げておきます。

( 一応書いておくと、「こういう場当たり的なやり方はいまいちだ」 ということは承知の上であえてやっている……という体なので、 今回のはあまり真面目に読まなくても大丈夫です。 )


「エッジが4本ならOK」と単純に判断してしまうと、たとえば

f:id:sonota88:20200301055428p:plain

こんな回路も OK になってしまいます。 こういうのはお断りしたいので、えーと……とりあえず、 「電池のプラス極、マイナス極につながっているエッジがそれぞれ 1本ずつ であれば OK」ということにしましょうか。

これだけでは全然厳密ではなくて、はっきり言ってかなりザルです。 が、 細かくチェックしようとするときりがない(たとえば、 e1, e4 上にスイッチがあるかとか……)のでいったん簡易に済ませます。


エッジ(の両端のうちいずれか)がプラス極・マイナス極につながっているかを判定するために Edge#connected_to?() を追加して、

  class Edge
    # ...

    def connected_to?(pos)
      @pos1 == pos || @pos2 == pos
    end
  end

1本ずつであることを判定。

  def update_4_edges
    edges_connected_to_plus =
      @edges.select { |edge|
        edge.connected_to?(@plus_poles[0].pos)
      }
    edges_connected_to_minus =
      @edges.select { |edge|
        edge.connected_to?(@minus_poles[0].pos)
      }

    if edges_connected_to_plus.size == 1 &&
       edges_connected_to_minus.size == 1
      # OK
    else
      raise "not yet implemented"
    end

    # TODO
  end

※ プラス極、マイナス極が複数ある場合も今は考えず、それぞれ 1個ずつ存在する前提で進めます……。

形の判定はひとまずこれでよしとして、通電判定に進みます。

(2) 通電の判定

  • 4本のエッジのうち、プラス極・マイナス極に直接つながっていない 真ん中の 2本のエッジ(e2e3)に着目する
  • e2e3 のそれぞれについて、
    • エッジ上のスイッチがすべて ON になっていたら「通電している」と判定する
  • e2e3 のいずれかが通電していれば、 プラス極・マイナス極に直接つながっている 2本のエッジ(e1e4)も「通電している」と判定する

この手順でなんとかなりそうな気がします。

参考までに図を再掲。

f:id:sonota88:20200301052456p:plain


真ん中の 2本のエッジ(e2e3)はこれで取り出せます。

    edge_connected_to_plus = edges_connected_to_plus[0]
    edge_connected_to_minus = edges_connected_to_minus[0]

    center_edges =
      @edges.reject { |edge|
        edge == edge_connected_to_plus ||
        edge == edge_connected_to_minus
      }

さて、取り出せたら次は e2e3 それぞれについて通電判定を行いますが、 ここでエッジとスイッチの対応関係について考慮する必要があります。

たとえば e2 について処理するときは、e2 上にあるスイッチ s1 だけを考えればよく、 s2 は無視してよいでしょう。

同様に、 e3 について処理するときは、e3 上にあるスイッチ s2 だけを考えればよく、 s1 は無視してよいでしょう。

要するに、処理対象にしている 1本のエッジ上にあるスイッチだけを考慮すればよいということです。

なので、あるエッジ上に載っているスイッチを探すところから始めます。


WireFragment#connected_to?()Edge#include_pos?() を追加。

module Unit
  class WireFragment
    def connected_to?(pos)
      @pos1 == pos || @pos2 == pos
    end
  end

  class Edge
    def include_pos?(pos)
      @wfs.any? { |wf| wf.connected_to?(pos) }
    end
  end

ちょっと非効率ですが先に進みます。


エッジ上にあるスイッチだけを集めて、 あとは前回と同様に Tuden.tuden?() に渡し、 エッジの通電状態を更新します。

    center_edges.each { |edge|
      switches = @switches.select { |switch|
        edge.include_pos?(switch.pos)
      }
      is_tuden = Tuden.tuden?(switches)
      edge.update(is_tuden)
    }

そして、e2e3 の通電状態をもとに e1e2 の通電状態を決めて更新。

    is_tuden_center =
      center_edges.any? { |edge| edge.on? }

    edge_connected_to_plus.update(is_tuden_center)
    edge_connected_to_minus.update(is_tuden_center)

いろいろ雑でゆるゆるなのですが、とにかく動くようにはなりました。 簡単のためスイッチ 2個で説明してきましたが、 せっかくなのでスイッチ 4個にした場合の動作例を貼っておきます。

f:id:sonota88:20200301072432g:plain

https://sonota88.github.io/kairo-gokko/pages/14/index.html で試せます。※マウス操作のできないスマホタブレットは非対応。 )

実装を知らない人が表面的な動作だけ見たら 「へー、ちゃんと動いてるじゃん」 と言ってくれるんじゃないでしょうか?



kairo-gokko (13) 通電 3



スイッチが複数ある場合に対応します。

これは全然大した修正じゃないので記事分ける必要なかったですね……。

--- a/circuit.rb
+++ b/circuit.rb
@@ -264,7 +264,7 @@ class Circuit
   end
 
   def update_edges
-    is_tuden = Tuden.tuden?(@switches[0])
+    is_tuden = Tuden.tuden?(@switches)
     @edges[0].update(is_tuden)
   end
--- a/tuden.rb
+++ b/tuden.rb
@@ -1,5 +1,5 @@
 class Tuden
-  def self.tuden?(switch)
-    switch.on?
+  def self.tuden?(switches)
+    switches.all? { |switch| switch.on? }
   end
 end

動きも問題ないようです。

f:id:sonota88:20200229162023g:plain



kairo-gokko (12-2) 通電 2



次はもうちょっと複雑なパターンを……と思いましたが、 その前にエッジ 1本の通電判定処理だけ作ってみることにします。

動くものができるとモチベーションも上がるでしょう。


  • クリックでスイッチだけ状態を更新
  • スイッチの状態に応じてエッジの通電状態を更新

の2段階に分けて実装します。

スイッチの状態を更新

マウスでスイッチ(の位置)をクリックしたことを検出して Switch#toggle() で状態をトグルするようにしました。

--- a/main.rb
+++ b/main.rb
@@ -42,7 +42,15 @@ Window.load_resources do
     my = (Input.mouse_y / PPC).floor
 
     if Input.mouse_push?(M_LBUTTON)
-      Sound[:click].play
+      mpos = Point(mx, my)
+
+      pushed_switch =
+        circuit.switches.find { |switch| switch.pos == mpos }
+
+      if pushed_switch
+        pushed_switch.toggle()
+      end

       Sound[:click].play
     end
 
     view.draw_grid(8, 10)

スイッチの状態に応じて表示色を変えて、

--- a/view.rb
+++ b/view.rb
@@ -66,6 +66,8 @@ class View
   end
 
   def draw_switch(switch)
+    color = switch.on? ? C_ACTIVE : C_INACTIVE
+
     @drawer.draw_box_fill(
       switch.x + 0.1, switch.y + 0.1,
       switch.x + 0.9, switch.y + 0.9,

 ... snip ...

同じように、状態に応じてスイッチの四角の中の横棒の位置を変えます。

f:id:sonota88:20200229124246g:plain

クリックで色と形が変わるようになり、スイッチ感が出てきました!

エッジの状態を更新

スイッチと同様に、Unit::Edgeインスタンス変数 @state と、状態参照・更新のために #on?()#update() を追加して、通電判定用のクラス Tuden を追加しました。

ひとまずこれだけ。

class Tuden
  def self.tuden?(switch)
    switch.on?
  end
end

名前をどうするか悩みましたが、どれもしっくりこなかったので Tuden で進めます。 いいんですこれで。ユビキタス言語だから。たぶん。 いい名前を思いついたら後でリネームします……。


Circuit#update_edges() を追加。 まずは曳光弾を通したいので、エッジ 1本、スイッチ 1本のケースだけを考えます。

class Circuit
  # ...
  def update_edges
    is_tuden = Tuden.tuden?(@switches[0])
    @edges[0].update(is_tuden)
  end

  # ...

いやー、この is_tuden という名前もひどいなあ。はっはっは。


Circuit#update_edges() をメインループから呼び出します。

Window.load_resources do
  Window.bgcolor = C_BLACK

  Window.loop do
    mx = (Input.mouse_x / PPC).floor
    my = (Input.mouse_y / PPC).floor

    if Input.mouse_push?(M_LBUTTON)
      mpos = Point(mx, my)

      pushed_switch =
        circuit.switches.find { |switch| switch.pos == mpos }

      if pushed_switch
        Sound[:click].play
        pushed_switch.toggle()
      end
    end

    circuit.update_edges()

    # 以下描画処理

あとはスイッチの場合と同様に、エッジの状態に応じて描画色を変えれば……

  def draw_edge(edge)
    color = edge.on? ? C_ACTIVE : C_INACTIVE

    # ...

f:id:sonota88:20200229133435g:plain

https://sonota88.github.io/kairo-gokko/pages/12/index.html で試せます。※マウス操作のできないスマホタブレットは非対応。 )

できました!

スイッチの切り替えでエッジの通電状態が変化しています(変化しているように見えます)!

まだエッジ 1本にスイッチ 1個だけですが、 やはり自分の操作でインタラクティブに動くようになると楽しいですね。


まずはスイッチ1個の場合の曳光弾が通りました。 次は複数のスイッチに対応させましょう。



kairo-gokko (12-1) 通電 1



見た目ばかり整えていても動きません。いいかげん本丸である通電に取り組みましょう。

いきなり難しいところから考えるといきなり手詰まりになってしまうので、 まずは簡単なところから始めます。 電気(回路)の知識に乏しいのと、こういうプログラムを作るのが初めてなので、ここらへん手探りです。


まずはこれ。 通電している(通電するだろうと考えている)エッジには色を付けました。

f:id:sonota88:20200229080331p:plain

これは簡単。 電池のプラス極 [+] とマイナス極 [-] が直結していれば、そりゃ通電しますよね。

実際に(物理的に)この回路を作るとショートすると思うんですが、ショートについてはこの先も考えないことにします。 電圧も抵抗も考えません。話がややこしくなるから。 通電するかしないかだけを考えます。


f:id:sonota88:20200229082403p:plain

さっきの図では [+][-] が隣同士になるように配置していましたが、離して置いても同じ回路(同じように動作する回路)だと考えます。

f:id:sonota88:20200229084544p:plain

たとえば、実際はこういうふうにちゃんと電池につながっているけど回路図の上では省略しているだけなのだ、とか、

f:id:sonota88:20200229083601p:plain

こういう奇妙な形の電池(網かけの部分)を使っているけど回路図の上では省略しているだけなのだ、とか。 そう考えると一応納得できます。

(※ ここで「納得できる」と言っているのは、「これ物理的に作れるの?」とか「これほんとに動くの?」などとモヤモヤせずに済み、「作ろうと思えば物理的に作れそうだ」という気分になれることです。 抽象化された図から具体的な例を考えられるか、と言ってもいいかもしれません。 電気ド素人なのでこういうとこが気になってしまうのです……。)

あるいは、奇妙な形の電池で、両端の極以外の胴体部分が透明になっていて見えないとか……まあここらへんでやめて次に行きましょうか。


f:id:sonota88:20200229091104p:plain

途中で導線が途切れていると通電しません。当たり前。

図の見た目では「1本のエッジが途中で切れている」ように見えるかもしれませんが、2本のエッジです。


f:id:sonota88:20200229094211p:plain

こういうパターンも通電しないでしょうね。これらはエッジ 1本。


f:id:sonota88:20200229091501p:plain

行き止まりになっているエッジは通電しなさそう。

これはそういう回路を作らなければいいので無視してもいいんですが、考えられるパターンとしてついでに挙げてみました(ちなみにこの場合はエッジ 3本)。


スイッチも考えてみます。

f:id:sonota88:20200229091836p:plain

スイッチが OFF だと通電しない。スイッチが ON だと通電する。

図では OFF の場合は線が途中で切れているように見えますが、 この場合はエッジは 2本とせず、「1本のエッジの上にスイッチが載っている」と考えることにします。 これはそう考えなければいけない訳ではなく、ルール決めというかモデル化の問題です。 そういうモデルにしました。


スイッチが複数ある場合。

f:id:sonota88:20200229092031p:plain

この場合もエッジは 1本です。1本のエッジにスイッチが複数載っている。

1個でも OFF のスイッチがあると通電しない。 すべてのスイッチが ON になっている場合のみ通電する。


というわけで、主にエッジが 1本のパターンについて思いつくままに考えてみました。 とりあえずこんなところでしょうか。



kairo-gokko (11) スイッチを追加



導線と電池のプラス極・マイナス極だけでは寂しいですね。とりあえずスイッチを追加しましょうか。 まだ電気を流して回路を動かすことはできないので描画するだけです。


LibreOffice Draw で元データを作ります。 data_01.fodg の 4 ページ目を追加。 sw というテキストを持っている矩形を追加します。

f:id:sonota88:20200228065203p:plain

後は今までやってきたことと大体同じですね。


Unit::Switch クラスを追加。

module Unit
  # ...
  class Switch < SingleCell
    def initialize(pos)
      super

      # ON: true / OFF: false
      @state = false
    end

    def to_plain
      {
        pos: @pos.to_plain
      }
    end

    def self.from_plain(plain)
      Switch.new(
        Point.from_plain(plain["pos"])
      )
    end
  end

テキストが sw になっている矩形を探して Switch オブジェクトにします。

class Circuit
  # ...
  def self.create(lines, rects)
    # ...

    switches =
      rects
        .select { |rect| rect.text == "sw" }
        .map { |rect| to_switch(rect) }

    # ...

描画。

class View
  # ...
  def draw_switch(switch)
    @drawer.draw_box_fill(
      switch.x + 0.1, switch.y + 0.1,
      switch.x + 0.9, switch.y + 0.9,
      C_BLACK
    )

    @drawer.draw_box(
      switch.x + 0.1, switch.y + 0.1,
      switch.x + 0.9, switch.y + 0.9,
      C_INACTIVE
    )

    @drawer.draw_box_fill(
      switch.x + 0.3, switch.y + 0.6,
      switch.x + 0.7, switch.y + 0.7,
      C_INACTIVE
    )
  end

BROWSER=1 ./run.sh data_01.fodg 4
で実行するとこんな感じです。

f:id:sonota88:20200228065632p:plain

ついでに色を調整しました。 まだ電気を流せていないので、導線とスイッチは暗い緑色にしています。


形がスイッチっぽく見えないかもしれませんが、

f:id:sonota88:20200313045810p:plain

たとえばこういう形だと方向を気にしないといけなくて (縦か横かで表示を切り替えないといけない)めんどくさいんですよね。 配線の角の部分に置くのは禁止すべきか? とか考えないといけないし。

最低限 ON/OFF の状態が分かればいい訳ですし、 ひとまず縦でも横でも両方対応できる形にしました。 後で気が変わったらいくらでも凝ったものにすればいいと思います。



kairo-gokko (10-2) dxopal_sdl.rb を使う



前の記事に書いた dxopal_sdl.rb を使う形に書き換えます。


ここは上記の記事に書いた通り。

--- a/main.rb
+++ b/main.rb
@@ -1,4 +1,13 @@
-require 'dxopal'
+def browser?
+  Kernel.const_defined?(:Native)
+end
+
+if browser?
+  require "dxopal"
+else
+  require "./dxopal_sdl"
+end
+
 include DXOpal
 
 require_remote "./data.rb"

実行時に環境変数で切り替えるように run.sh を修正。

--- a/run.sh
+++ b/run.sh
@@ -7,4 +7,8 @@ bundle exec ruby gen_sound.rb \
 
 ruby preprocess.rb "$@" > data.rb
 
-bundle exec dxopal server
+if [ "$BROWSER" = "1" ]; then
+  bundle exec dxopal server
+else
+  bundle exec ruby main.rb
+fi

ブラウザで動かすときは BROWSER=1 ./run.sh ... のように実行します。

SDL版で動かすときは BROWSER=0 ./run.sh ... のように実行します。 BROWSER=1 でなければ何でもいいので環境変数自体を省略してもOK。


もう一箇所、JSON のパース部分が動かなくなるのでここも直します。

--- a/main.rb
+++ b/main.rb
@@ -18,7 +18,12 @@ require_remote "./view.rb"
 PPC = 30
 
 def parse_json(json)
-  Native(`JSON.parse(json)`)
+  if browser?
+    Native(`JSON.parse(json)`)
+  else
+    require "json"
+    JSON.parse(json)
+  end
 end

これでよし。 DXOpal 版と SDL 版が両方動くようになりました。



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

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



kairo-gokko (9) マウスと効果音



「マウス操作も効果音もまだ必要にはなってない」と前回書きましたが、 せっかく DXOpal にしたのでちょっとだけやっておきます。

マウス

とりあえずマウスカーソルの位置(x, y 座標)を取得して、表示に反映させてみます。

--- a/main.rb
+++ b/main.rb
@@ -22,6 +22,9 @@ Window.load_resources do
   Window.bgcolor = C_BLACK
 
   Window.loop do
+    mx = (Input.mouse_x / PPC).floor
+    my = (Input.mouse_y / PPC).floor
+
     view.draw_grid(8, 10)
 
     circuit.plus_poles.each { |pole|
@@ -35,5 +38,7 @@ Window.load_resources do
     circuit.edges.each { |edge|
       view.draw_edge(edge)
     }
+
+    view.draw_cursor_highlight(mx, my)
   end
 end

mx, my はセル座標です。マウスカーソルがある位置のセルがハイライトされるようにしてみました。

f:id:sonota88:20200224105905g:plain

効果音

効果音の音声ファイルを作ります。 とりあえず今は音が出れば何でもよいです。 wavefile gem を使って適当な三角波を作ってみました。

参考: Rubyで音(wav)を書き出してみた - Qiita

※ 作った後で気づきましたが、こういう場合は SoundEffect を使うのもよさそうです。
SoundEffectチュートリアル(DXRuby)

# coding: utf-8
require "wavefile"
include WaveFile

def osc_tri(ratio)
  if ratio < 0.25
    4 * ratio
  elsif ratio < 0.75
    -4 * ratio + 2
  else
    4 * ratio - 4
  end
end

# --------------------------------

params = {}

ARGV.map { |arg|
  md = /^(.+?)=(.+)/.match(arg)
  k = md[1].to_sym
  v = md[2]

  params[k] =
    case k
    when :msec, :hz, :amp
      v.to_f
    when :out
      v
    else
      raise "invalid key (#{k})"
    end
}

# --------------------------------

# サンプリングレート(サンプル数 / 秒)
srate = 44100

# 増幅率 (0.0 <= x <= 1.0)
amp = params[:amp] || 0.1

duration_msec = params[:msec] || 100.0

# 全体のサンプル数
num_samples = srate * (duration_msec.to_f / 1000)

hz = params[:hz] || 440.0

# 1周期あたりのサンプル数
num_samples_per_cycle = srate / hz

out_file = params[:out] || "output.wav"

# --------------------------------

samples = []

(0 ... num_samples).each { |t|
  t_in_cycle = t % num_samples_per_cycle
  ratio = t_in_cycle / num_samples_per_cycle
  samples << osc_tri(ratio) * amp
}

# --------------------------------

buffer_format = Format.new(:mono, :float, srate)
file_format = Format.new(:mono, :pcm_16, srate)

buffer = Buffer.new(samples, buffer_format)

Writer.new(out_file, file_format) do |writer|
  writer.write(buffer)
end

たかだか数十ミリ秒の小さなデータを生成するだけなので実行は 0.3 秒くらいで終わります。 なので、生成処理も富豪的run.sh で毎回実行するようにしました。

--- a/run.sh
+++ b/run.sh
@@ -2,6 +2,8 @@
 
 set -o errexit
 
+bundle exec ruby gen_sound.rb \
+  out=click.wav amp=0.1 msec=30 hz=1000
+
 ruby preprocess.rb "$@" > data.rb
 
 bundle exec dxopal server

ファイルが用意できたので main.rb で使ってみましょう。

--- a/main.rb
+++ b/main.rb
@@ -18,6 +18,8 @@ circuit = Circuit.from_plain(
 
 view = View.new(PPC)
 
+Sound.register(:click, "click.wav")
+
 Window.load_resources do
   Window.bgcolor = C_BLACK
 
@@ -25,6 +27,10 @@ Window.load_resources do
     mx = (Input.mouse_x / PPC).floor
     my = (Input.mouse_y / PPC).floor
 
+    if Input.mouse_push?(M_LBUTTON)
+      Sound[:click].play
+    end
+
     view.draw_grid(8, 10)
 
     circuit.plus_poles.each { |pole|

マウスの左ボタンをクリックしたときに効果音が鳴るようになりました。 お手軽ですね!


せっかくの DXOpal なので GitHub Pages に置いてみました。

スマホタブレットだと描画はされますがマウス操作はできません。 DXOpal 側ではタッチまわりの API が用意されていますが、いったんマウスのみの対応で進めます。

https://sonota88.github.io/kairo-gokko/pages/09/index.html

参考: DXOpalで作ったゲームを無料で公開する - Qiita