kairo-gokko (25) 状態変更の伝播の過程を見たい



前回は状態更新処理をループさせることで 2段、3段とリレーが連なった場合でも動くようにしました。

前回の回路はうまく動きましたが、そういえば無限ループになる場合はないのでしょうか?

あります。 たとえば次の回路では無限ループが発生します。

f:id:sonota88:20200316053355p:plain

困ったことに初回の状態更新で無限ループが発生し、 いつまでたっても画面に何も描画されません。

うーむ、困りましたね…… とりあえずは無限ループが発生したら異常終了させときましょうか。 つまり、こういう状態更新が収束しないようなウロボロス的回路は不正とみなすことにするわけです。

--- a/main.rb
+++ b/main.rb
@@ -57,8 +57,11 @@ end
 
 def update_tuden_relay_switch_lamp(circuit)
   switch_changed = true
+  count = 0
 
   while switch_changed
+    count += 1
+    raise "Too many state updates" if 1000 < count
+
     circuit.update_tuden_state()
     switch_changed = circuit.update_not_relays_state()
     circuit.update_lamps_state()

とりあえずはこれでいいかな……と思ってしばらくこれで進めていましたが、 次に挙げる 2つの理由により、無限ループになるような回路でも不正とみなさず動かし続けることにしました。


理由の1つめは、ループが止まらない回路があとで実際に登場してくるためです。

今作っているのは 2周目で、あとで必要になることがすでに分かっているので、 もうやってしまってもいいかなと。

しばらくは「そういう回路を描かなければいい」で回避してもいいんですけどね (プロトタイプのときは実際そうしてました)。 上に上げた回路の例も無限ループになる例として挙げただけであって、今そういう回路を動かしたいわけではありませんし。

なので、1つ目は今の時点ではちょっと弱い理由です。


もう一つの理由の説明のために、子回路が4つある前回の回路を動かした GIF 画像を再掲します。

f:id:sonota88:20200315143609g:plain

これ、うまく動いて万々歳でしたね。すばらしい。

すばらしくはありますが、何かこう、物足りなさが……。

何が物足りないか?

一番左のスイッチをマウスでクリックして切り替えると、 4つの子回路の状態が瞬時に切り替わります。

瞬時に切り替わるのはなぜか。 それは、一番左のスイッチを切り替えると、 状態変更が収束するまでループして、 状態変更が止まったらようやくそこで結果だけをポンと描画しているからですね。

リレーによって順番に左から右へと影響が波及しているはずなのに、 最終的な結果だけが一瞬で表示される作りになっているため、 その過程が見えず、つまらないなと。 あと、せっかくがんばって(?)処理してるのにそれが見えないのはちょっともったいないような。

また、「理屈ではそうなると分かっていて、実際に動かした結果も予想に合致しているけど、実感が伴わない」ような感じもあります。

ドミノのように、パタパタと順番に切り替わっていく様子が目に見えると、 ピタゴラ装置感が出ておもしろくなるんじゃないでしょうか!?

……というのが 2つ目の理由です。 どっちかというとこっちの理由の方を優先します。


というわけで、「結果だけポンと描画」方式をやめて、 影響が波及していく過程が見えるように変更します。 これにより、状態変更が収束しない回路も動かし続けられるようになります。


まずはさっきの修正(無限ループが発生したら異常終了するようにする)を取り消し。


それから

  • (1) while ループをなくし、update_tuden_relay_switch_lamp() 内では状態更新を繰り返さないようにする
  • (2) ローカル変数 switch_changed の代わりに Circuit#switch_changed に(リレーによる)スイッチの状態変化を記憶させる
    • メソッドのスコープを越えて記憶させておくため
  • (3) update_tuden_relay_switch_lamp() を呼び出す条件に Circuit#switch_changed を追加
    • 手動によるスイッチの変化、またはリレーによるスイッチの変化があったら 通電判定以下をやりなおす
--- a/circuit.rb
+++ b/circuit.rb
@@ -13,6 +13,7 @@ end
 
 class Circuit
   attr_reader :child_circuits
+  attr_accessor :switch_changed
 
   def initialize(child_circuits)
     @child_circuits = child_circuits


--- a/main.rb
+++ b/main.rb
@@ -56,13 +56,9 @@ def on_push_switch(pushed_switch)
 end
 
 def update_tuden_relay_switch_lamp(circuit)
-  switch_changed = true
-
-  while switch_changed
     circuit.update_tuden_state()
-    switch_changed = circuit.update_not_relays_state()
+    circuit.switch_changed = circuit.update_not_relays_state()
     circuit.update_lamps_state()
-  end
 end
 
 def draw(view, circuit, mx, my)
@@ -130,7 +126,7 @@ def main_loop(circuit, view)
     end
   end
 
-  if switch_changed
+  if switch_changed || circuit.switch_changed
     update_tuden_relay_switch_lamp(circuit)
   end

さらっとした diff ですが、ここらへんもプロトタイプの時はいろいろあったんですよ…… (割愛)。

これで途中のステップごとに描画されるようになったはずなんですが、実際動かしてみると速すぎて一瞬で切り替わってるように見えますね……。

そこで、一定の時間が過ぎるまで状態更新をスキップするような制御を追加します。

--- a/circuit.rb
+++ b/circuit.rb
@@ -14,9 +14,11 @@ end
 class Circuit
   attr_reader :child_circuits
   attr_accessor :switch_changed
+  attr_accessor :last_update
 
   def initialize(child_circuits)
     @child_circuits = child_circuits
+    @last_update = Time.at(0)
   end
 
   def to_plain


--- a/main.rb
+++ b/main.rb
@@ -56,6 +56,9 @@ def on_push_switch(pushed_switch)
 end
 
 def update_tuden_relay_switch_lamp(circuit)
+  return if Time.now < circuit.last_update + 0.2
+  circuit.last_update = Time.now
+
   circuit.update_tuden_state()
   circuit.switch_changed = circuit.update_not_relays_state()
   circuit.update_lamps_state()

こうなりました!

f:id:sonota88:20200317053554g:plain

うーん、良い。断然こっちの方が楽しいでしょ。

f:id:sonota88:20200317054754g:plain

状態更新が収束しない回路も動かせるようになりました!


あ、そうだ、状態更新のタイミングがずれるようになったので、 リレーの状態が変化したときも音を出すようにしましょう。

--- a/main.rb
+++ b/main.rb
@@ -62,6 +62,8 @@ def update_tuden_relay_switch_lamp(circuit)
   circuit.update_tuden_state()
   circuit.switch_changed = circuit.update_not_relays_state()
   circuit.update_lamps_state()
+
+  Sound[:relay].play if circuit.switch_changed
 end
 
 def draw(view, circuit, mx, my)
@@ -140,14 +142,15 @@ end
 
 circuit = Circuit.from_plain(parse_json($data_json))
 
-update_tuden_relay_switch_lamp(circuit)
 
 view = View.new(PPC)
 
 Sound.register(:click, "click.wav")
+Sound.register(:relay, "relay.wav")
 
 Window.load_resources do
   hide_loading()
+  update_tuden_relay_switch_lamp(circuit)
 
   Window.bgcolor = C_BLACK
 

--- a/run.sh
+++ b/run.sh
@@ -11,6 +11,9 @@ fi
 bundle exec ruby gen_sound.rb \
   out=click.wav amp=0.05 msec=30 hz=1000
 
+bundle exec ruby gen_sound.rb \
+  out=relay.wav amp=0.05 msec=30 hz=500
+
 ruby preprocess.rb "$@" > data.rb
 
 if [ "$BROWSER" = "1" ]; then

以下の iframe で実際に動かせます。 音量小さめにしていますが音が出ます。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/25/index.html



kairo-gokko (24) リレー 3



いやー、なにはともあれ NOT ゲートが動きましたね。

とにかく動くということが分かったので、ハリボテ部分をまともな形に修正していきます。


現状ではどこがハリボテかというと、リレーによるスイッチへの作用が2段階以上になる次のような回路が正しく動いてくれません。

f:id:sonota88:20200315124541p:plain

ではどうするか。 状態更新の処理を繰り返せばよさそうですね。

前回は 2回実行されるように単純にコピペしましたが、 これをループの形に書き直します。

# before
circuit.update_tuden_state()
circuit.update_not_relays_state()
circuit.update_lamps_state()

circuit.update_tuden_state()
circuit.update_not_relays_state()
circuit.update_lamps_state()

# after
while ???
  circuit.update_tuden_state()
  circuit.update_not_relays_state()
  circuit.update_lamps_state()
end

ループにするとなると、今度は停止条件を考えなければいけません。

スイッチの状態更新
→ 通電状態の更新
→ リレーの状態更新
→ スイッチの状態更新
→ 通電状態の更新
→ …

状態更新はこんな順番で実行され、繰り返されることになります。

ランプの状態更新は通電状態から影響を受けることはあっても他に影響を与えることはないため無視します。

リレーの状態更新と、リレーによるスイッチの更新はひとセット (リレーの状態が変わったら必ずスイッチの状態も変わる) ということにしてまとめましょうか。

スイッチの状態更新
→ 通電状態の更新
→ リレーとスイッチの状態更新
→ 通電状態の更新
→ リレーとスイッチの状態更新
→ 通電状態の更新
→ …

ふーむ。となると、判断するタイミングは

  • 通電状態の更新がなかったらループを抜ける
    • 通電判定した結果、状態が変化するエッジがあったかを検出する
  • リレーとスイッチの状態更新がなかったらループを抜ける
    • 状態が変化するリレーまたはスイッチがあったかを検出する

の 2パターンありそうです。どっちがいいんでしょうか。


しばし考え……

考えた結果、「どちらでもよさそう」と思えました。


通電の状態更新の結果で判断する場合:

  • (1) 通電の状態を更新する
    • その結果、状態が変化するエッジがあった
    • リレー・スイッチの状態変更を行う必要がある
    • ループを抜けない
  • (2) リレー・スイッチの状態を更新する
  • (3) 通電の状態を更新する
    • その結果、状態が変化するエッジがなかった
    • リレー・スイッチの状態変更を行う必要がない
    • ループを抜ける

リレーまたはスイッチの状態更新の結果で判断する場合:

  • (1) リレー・スイッチの状態を更新する
    • その結果、状態が変化するリレーまたはスイッチがあった
    • 通電の状態変更を行う必要がある
    • ループを抜けない
  • (2) 通電の状態を更新する
  • (3) リレー・スイッチの状態を更新する
    • その結果、状態が変化するリレーまたはスイッチがなかった
    • 通電の状態変更を行う必要がない
    • ループを抜ける

ちょっと不思議な感じ……というか、ちゃんと理解できてないんじゃないかという気もしますが、ひとまず「どっちでもよい」という判断で進めます。

どっちでもよいなら簡単な方がいいですね。 通電判定の方はエッジが多いとちょっとめんどくさそうな気がします。 ややこしいことをやっている部分なので、できればそっとしておきたい。 リレーorスイッチの状態の変化を使うことにしましょう。

リレーとスイッチ 2つの内ではどっちかというと…… 影響の流れは リレー → スイッチ → エッジ であって、 エッジの通電に直接影響を与えているのはリレーではなくスイッチなので、 スイッチの状態の変化を使う、ということにします。


スイッチの状態の変化を使うといったん決めた上で改めて考えてみると……

スイッチの状態更新
→ 通電状態の更新
   (A) ここで判断するか、または
→ リレーとスイッチの状態更新
   (B) ここで判断するか
→ …

(B) のタイミングで判断する場合、通電状態の変化がなければ リレーとスイッチの状態の変化もない (リレーとスイッチの状態更新の処理は実行されるが空振りする)。

……と考えると……?   悪くない気も、やっぱりなんか怪しい感じもしますが、とにかく作って動かしてみます。


では修正。

同じ処理が 2箇所(初回の更新とメインループ内)あるので先にリファクタリングしておきましょうか。

--- a/main.rb
+++ b/main.rb
@@ -55,6 +55,16 @@ def on_push_switch(pushed_switch)
   pushed_switch.toggle()
 end
 
+def update_tuden_relay_switch_lamp(circuit)
+  circuit.update_tuden_state()
+  circuit.update_not_relays_state()
+  circuit.update_lamps_state()
+
+  circuit.update_tuden_state()
+  circuit.update_not_relays_state()
+  circuit.update_lamps_state()
+end
+
 def draw(view, circuit, mx, my)
   view.draw_grid(11, 11)
 
@@ -121,13 +131,7 @@ def main_loop(circuit, view)
   end
 
   if switch_changed
-    circuit.update_tuden_state()
-    circuit.update_not_relays_state()
-    circuit.update_lamps_state()
-
-    circuit.update_tuden_state()
-    circuit.update_not_relays_state()
-    circuit.update_lamps_state()
+    update_tuden_relay_switch_lamp(circuit)
   end
 
   draw(view, circuit, mx, my)
@@ -137,13 +141,7 @@ end
 
 circuit = Circuit.from_plain(parse_json($data_json))
 
-circuit.update_tuden_state()
-circuit.update_not_relays_state()
-circuit.update_lamps_state()
-
-circuit.update_tuden_state()
-circuit.update_not_relays_state()
-circuit.update_lamps_state()
+update_tuden_relay_switch_lamp(circuit)
 
 view = View.new(PPC)

微妙な名前のメソッドに抽出しました。 かっこつけて何やってるかよく分からない名前にするくらいならストレートな名前の方がまだよい……ということにしておきたい……。


スイッチの状態変化の検出は ChildCircuit#update_not_relays で行います。

--- a/child_circuit.rb
+++ b/child_circuit.rb
@@ -220,13 +220,22 @@ class ChildCircuit
   end
 
   def update_not_relays(circuit)
+    switch_changed = false
+
     @not_relays.each { |not_relay|
       edge = @edges.find { |edge| edge.include_pos?(not_relay.pos) }
       not_relay.update(edge.on?)
 
       neighbor_switch = circuit.find_neighbor_switch(not_relay.pos)
+      state_before_update = neighbor_switch.on?
       neighbor_switch.update(! not_relay.on?)
+
+      if neighbor_switch.on? != state_before_update
+        switch_changed = true
+      end
     }
+
+    switch_changed
   end
 
   def pretty_inspect

Circuit#update_not_relays_state も似た感じで。

--- a/circuit.rb
+++ b/circuit.rb
@@ -389,8 +389,16 @@ class Circuit
   end
 
   def update_not_relays_state
+    switch_changed = false
+
     @child_circuits.each { |child_circuit|
-      child_circuit.update_not_relays(self)
+      _switch_changed = child_circuit.update_not_relays(self)
+
+      if _switch_changed
+        switch_changed = true
+      end
     }
+
+    switch_changed
   end
 end

スイッチの変化が得られるようになったら、 それを使ってループを抜けるようにします。

# main.rb

def update_tuden_relay_switch_lamp(circuit)
  switch_changed = true

  while switch_changed
    circuit.update_tuden_state()
    switch_changed = circuit.update_not_relays_state()
    circuit.update_lamps_state()
  end
end

f:id:sonota88:20200315143410g:plain

うまく動いているようです……!

もう一つ増やしてもいけるはず!

f:id:sonota88:20200315143609g:plain

ヨシ!


以下の iframe で実際に動かせます。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/24/index.html



kairo-gokko (23) リレー 2



前回は not リレーの表示まで実装しました。 今回は、リレーの通電状態が変わったときに隣のスイッチを切り替える部分を作りましょう。

汎用的なものを目指すのはいったん脇に置いといて、まずはこの回路を動かすことだけを考えて様子を探ります。最初はハリボテでOK。

f:id:sonota88:20200313094423p:plain


今、通電やスイッチなどの状態更新まわりの処理の流れはこうなっています。

- スイッチの状態更新
- スイッチの状態が変化したら以下を実行
  - 通電状態を更新
  - リレーの状態を更新
  - ランプの状態を更新

リレーの状態が変わった場合は隣のスイッチの状態も更新する必要があるので……こうでしょうか。

- スイッチの状態更新
- スイッチの状態が変化したら以下を実行
  - 通電状態を更新
  - リレーの状態を更新
    - このとき隣のスイッチの状態を更新 ★これを追加
  - ランプの状態を更新

スイッチの状態が変わったらまた通電判定をやりなおして、通電状態を更新しなければいけません。 そこで、通電状態の更新以下を繰り返してみます。

- スイッチの状態更新
- スイッチの状態が変わったら
  - 通電状態を更新
  - リレーの状態を更新
    - このとき隣のスイッチの状態を更新
  - ランプの状態を更新

  - 通電状態を更新
  - リレーの状態を更新
    - このとき隣のスイッチの状態を更新
  - ランプの状態を更新

これでどうでしょうか。やってみましょう。


えーっとまずは……リレーが作用する相手のスイッチを見つける必要がありますね。そこを作りましょう。

リレーは左の子回路にあり、相手のスイッチは右の回路にあります。 ということは、相手のスイッチを探す処理は子回路内に閉じた処理にはならないですね。

ではどうするかというと……ひとまず circuit にお願いして隣のスイッチを探してもらうことにします。

# class Circuit

  def update_not_relays_state
    @child_circuits.each { |child_circuit|
      child_circuit.update_not_relays(self)
    }
  end


# class ChildCircuit

  def update_not_relays(circuit)
    @not_relays.each { |not_relay|
      edge = @edges.find { |edge| edge.include_pos?(not_relay.pos) }
      not_relay.update(edge.on?)

      neighnor_switch = circuit.find_neighbor_switch(not_relay.pos)
    }
  end

隣のスイッチを探す部分は次のようにしました。

※ これまた非効率で、実行時に動的に探す必要はなかったりします。が、まずは富豪的にやってしまいます。

# class Circuit

  def find_neighbor_switch(pos)
    @child_circuits.each { |child_circuit|
      switch = child_circuit.find_neighbor_switch(pos)
      return switch if switch
    }

    nil
  end

# module Unit
#   class Point

    def translate(dx, dy)
      Point.new(@x + dx, @y + dy)
    end

# class ChildCircuit

  def neighbor?(pos1, pos2)
    pos1 == pos2.translate( 0, -1) ||
    pos1 == pos2.translate( 1,  0) ||
    pos1 == pos2.translate( 0,  1) ||
    pos1 == pos2.translate(-1,  0)
  end

  def find_neighbor_switch(pos)
    @switches.find { |switch|
      neighbor?(switch.pos, pos)
    }
  end

相手のスイッチが見つかったら、 そのスイッチの状態をリレーの状態に応じて更新します。

今作っているのは not リレーなので、 リレーと逆になるように状態をセットします。 トグルではないのがポイントですね。

--- a/child_circuit.rb
+++ b/child_circuit.rb
@@ -225,6 +225,7 @@ class ChildCircuit
       not_relay.update(edge.on?)
 
       neighbor_switch = circuit.find_neighbor_switch(not_relay.pos)
+      neighbor_switch.update(! not_relay.on?)
     }
   end
 

あとは、上で書いたように 通電状態の変更以下を単純に繰り返してあげます。

--- a/main.rb
+++ b/main.rb
@@ -124,6 +124,10 @@ def main_loop(circuit, view)
     circuit.update_tuden_state()
     circuit.update_not_relays_state()
     circuit.update_lamps_state()
+
+    circuit.update_tuden_state()
+    circuit.update_not_relays_state()
+    circuit.update_lamps_state()
   end
 
   draw(view, circuit, mx, my)

初回の状態更新の部分も同様に繰り返し。

--- a/main.rb
+++ b/main.rb
@@ -140,6 +140,10 @@ circuit.update_tuden_state()
 circuit.update_not_relays_state()
 circuit.update_lamps_state()
 
+circuit.update_tuden_state()
+circuit.update_not_relays_state()
+circuit.update_lamps_state()
+
 view = View.new(PPC)
 
 Sound.register(:click, "click.wav")

f:id:sonota88:20200315085917g:plain

おおー、動いた。ハリボテとはいえ、 not リレーによるスイッチの切り替えができるようになりました!

さらっとやってしまいましたが、なんと、記念すべき、最初の NOT ゲート が動くようになりました!!   めでたい!   🎉🎉🎉🎉

これは not リレーがなければできなかったことです。   すごい!   not リレーえらい!!   (今さらのように盛り上げ)

いやしかし、このリレーの登場以降の部分は自分にとって未知の世界 (本で読んだりしてボンヤリ知ってはいるけど実際に動くものを自分で作ったことはなかった) だったので、わくわくしますね!


以下の iframe で実際に動かせます。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/23/index.html



kairo-gokko (22) リレー 1



リレーを追加します!


リレーとは何か!?

説明しよう!   リレーとは電磁石で(つまり電気による制御で)スイッチの ON/OFF を切り替える装置である。 以上。

あとはググッて調べてください。

ググりました?   分かりましたね?   では次へ。


equal リレーと not リレーという 2種類のリレーを用意します。

  • equal リレー
    • リレー自体が通電している場合、隣のスイッチを ON にする
    • リレー自体が通電していない場合、隣のスイッチを OFF にする
    • 回路図では r=
  • not リレー
    • リレー自体が通電している場合、隣のスイッチを OFF にする
    • リレー自体が通電していない場合、隣のスイッチを ON にする
    • 回路図では r!

この「equalリレー」「notリレー」という呼び方はオレオレです。 細かいことをいうとリレーの種類ではなく接点の種類と構成がという話になるっぽいのですが、簡略化して、とにかくこの2種類があるのだということにします。


not リレーの方がおもしろいので、先にそっちから作りましょう。

こんな回路図を描きました。

f:id:sonota88:20200313094423p:plain

スイッチを追加したときと同様に、 方向を考慮すると作図とプログラムがめんどくさくなるので 「リレーの隣(上下左右)のセルにあるスイッチに対して作用する」ということにします。

考えることを減らすため、 リレーの作用の対象になるスイッチは 1個だけという想定にします。 1個のリレーの隣に複数のスイッチがあるような回路は描かないようにする。 そんな回路を描くやつが悪い。


今回は表示するところまで作りました。 回路データを読んで Unit::NotRelayインスタンスを作って to_plain して from_plain して、表示して、エッジの通電状態に応じて明暗を切り替え、です。 ここまではランプとほぼ同じなので細かい説明は略。

f:id:sonota88:20200313063945g:plain

こんな見た目にしてみました。not リレーなので ! です。

隣のスイッチを切り替える機能はまだ実装していないため、 左のスイッチを切り替えても右のスイッチには影響を及ぼしません。



kairo-gokko (21) ランプ



物理回路と違って導線自体が光って見えるため実用性という意味では必要性は薄めですが、 ランプを追加します。

通電していることがちょっとだけ分かりやすいとか、通電確認したい箇所を強調できるとか、光が灯るのは象徴的でよいとか言えなくもないですが、 「小学校の理科の実験といえば豆電球でしょ」みたいなノリもあります。


回路図ではこう。

f:id:sonota88:20200311060735p:plain

小文字の "l" だと分かりにくいかなと思い、大文字の "L" にしました。


まずは Unit::Lamp を追加。

# class Unit
  class Lamp < SingleCell
    def initialize(pos)
      super

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

    # ...

あとはスイッチなどを追加したときとほとんど同じなのでそこらへんのコードは省略します。

  • ChildCircuit#lamps を追加
    • to_plain(), from_plain() の修正
  • Drawer#draw_circle()#draw_circle_fill() を追加

それができたら描画してあげます。 とりあえず状態更新は置いといて、光らない状態で描画できました。

f:id:sonota88:20200311041413p:plain


状態の更新は特に難しくないですね。 エッジが通電していたらランプも光る。それだけ。

# class ChildCircuit

  def update_lamps
    @lamps.each { |lamp|
      edge = @edges.find { |edge| edge.include_pos?(lamp.pos) }
      lamp.update(edge.on?)
    }
  end

毎回 @edges.find するのはちょっと非効率そうですが、 ひとまずこれで。

通電判定の後に circuit 経由で呼び出してランプの状態を更新します。

def main_loop(circuit, view)
  # ...

  if switch_changed
    circuit.update_tuden_state()
    circuit.update_lamps_state()
  end

  # ...

f:id:sonota88:20200311042554g:plain

おお、見栄えが華やかになってやっぱりちょっといいですね。 楽しい。

以下の iframe で実際に試せます。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/21/index.html



kairo-gokko (20) タッチ操作対応など



スイッチ以外をクリックしたときにエラーになるのを修正

Circuit#find_switch_by_position を修正。 クリックした位置にスイッチがない場合に nil を返すように。 もっと早く直しておけばよかった……。

--- a/circuit.rb
+++ b/circuit.rb
@@ -328,5 +328,7 @@ class Circuit
           .find { |switch| switch.pos == pos }
       return pushed_switch if pushed_switch
     }
+
+    nil
   end
 end

タッチ操作対応

これまで PC メインで作業してきてタッチ操作対応は後回しでもいいかなと思ってたんですが、 ブログに貼ったりするとやはりスマホタブレットで触りたくなってきました。

マウスの場合とほとんど同じで大丈夫でした。 これももっと早くやっとけばよかったですね。

# main.rb

    tx = (Input.touch_x / PPC).floor
    ty = (Input.touch_y / PPC).floor

    if Input.touch_push?
      tpos = Point(tx, ty)

      pushed_switch =
        circuit.find_switch_by_position(tpos)

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

以下の iframe で実際に試せます。

ファイルが増えたためロード時間がだいぶ長くなってきました(特にモバイル環境)。 これはこれでなんとかしたい……。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/20/index.html


動くことが分かったのでリファクタリングします。

スイッチをクリック、またはタッチしたときの処理をメソッドに抽出。

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

無駄な通電判定を抑制

気づいてはいましたが、 通電判定は重い処理ですから、毎フレーム実行するのはかなり無駄です。 スイッチの状態が変わらなければ通電の状態を更新する必要はないはずです。

まあこれは明らかに無駄だと思うので、 測定せずにやってしまいます。


まずはスイッチが変更されたことを示す変数 switch_changed を導入して、スイッチが押されたときだけ true になるようにします。

--- a/main.rb
+++ b/main.rb
@@ -50,6 +50,8 @@ Window.load_resources do
   Window.bgcolor = C_BLACK
 
   Window.loop do
+    switch_changed = false
+
     mx = (Input.mouse_x / PPC).floor
     my = (Input.mouse_y / PPC).floor
 
@@ -61,6 +63,7 @@ Window.load_resources do
 
       if pushed_switch
         on_push(pushed_switch)
+        switch_changed = true
       end
     end
 
@@ -75,6 +78,7 @@ Window.load_resources do
 
       if pushed_switch
         on_push(pushed_switch)
+        switch_changed = true
       end
     end

この switch_changed を見て通電判定処理を実行するか決めるようにします。

    if switch_changed
      circuit.child_circuits.each { |child_circuit|
        child_circuit.update_edges()
      }
    end

これだけだと、アプリケーションの実行が開始されて最初にスイッチが押されるまで 通電判定が行われないことになってしまうので、 初回に一度実行するようにします。

--- a/main.rb
+++ b/main.rb
@@ -40,6 +40,10 @@ end
 
 circuit = Circuit.from_plain(parse_json($data_json))
 
+circuit.child_circuits.each { |child_circuit|
+  child_circuit.update_edges()
+}
+
 view = View.new(PPC)
 
 Sound.register(:click, "click.wav")

さらに、この子回路をイテレートして通電判定する部分は、責務的に Circuit に移した方がよさそうなので、 Circuit#update_tuden_state に抽出しました。

  def update_tuden_state
    @child_circuits.each { |child_circuit|
      child_circuit.update_edges()
    }
  end

んー、メインループ部分が長くなってきたのでこれもメソッドに抽出しましょうか。 main_loop() 自体は長いままなんですが、呼び出し側の部分はすっきりしました。

circuit = Circuit.from_plain(parse_json($data_json))
circuit.update_tuden_state()

view = View.new(PPC)

Sound.register(:click, "click.wav")

Window.load_resources do
  hide_loading()

  Window.bgcolor = C_BLACK

  Window.loop do
    main_loop(circuit, view)
  end
end

今回はここまで。



kairo-gokko (19) Circuitクラスを改めて追加 / リファクタリングなど



test/helper を追加

テストのファイルが 2つに増えたので、共通部分を helper にまとめておきます。

# test/helper.rb

require "minitest/autorun"

$LOAD_PATH.unshift File.expand_path("..", __dir__)

各テストファイルの require 部分がこうなりました。

# test/test_tuden.rb

require_relative "./helper"
require "tuden"

Circuit クラスを追加

回路全体を表し、子回路をたばねて統括するクラスです。 CircuitManager みたいな名前にしたくなるやつですね。

子回路(の配列)を生成する処理をファクトリーメソッド ChildCircuit.create で行っていましたが、 子回路は他の子回路について知らなくてよいはずです。 そこで、回路全体を生成する責務は Circuit クラスに負わせることにします。 ファクトリーメソッド Circuit.create を用意し、 ChildCircuit.create で行っていた内容をほぼそのまま移動。

# class Circuit

  def self.create(lines, rects)
    all_plus_poles = ...
    all_minus_poles = ...
    all_switches = ...

    wf_set = to_wire_fragments(lines)
    all_edges = to_edges(wf_set)

    edge_groups = to_edge_groups(all_edges)

    child_circuits =
      edge_groups.map { |edges|
        plus_poles  = select_child_circuit_units(edges, all_plus_poles)
        minus_poles = select_child_circuit_units(edges, all_minus_poles)
        switches    = select_child_circuit_units(edges, all_switches)

        ChildCircuit.new(
          edges,
          plus_poles,
          minus_poles,
          switches
        )
      }

    Circuit.new(child_circuits)
  end

それにともない、関連するメソッドとクラスをごっそり Circuit クラスに移動させました。

- to_plus_pole
- to_minus_pole
- to_switch
- to_wire_fragments
- make_degree_map
- select_start_points
- make_pt_wfs_map
- select_next_wfs
- take_edge
- to_edges
- EdgeCluster
- to_edge_groups
- select_child_circuit_units

ChildCircuit には何が残ったかというと、 ほとんど通電判定関連のメソッドですね。 通電判定は子回路ごとの関心事なので、ChildCircuit に置いたままにした方が適切でしょう。


main.rb のトップレベルに置いていたこういうメソッドも Circuit クラスに移動。

  def find_switch_by_position(pos)
    @child_circuits.each { |child_circuit|
      pushed_switch =
        child_circuit.switches
          .find { |switch| switch.pos == pos }
      return pushed_switch if pushed_switch
    }
  end

dxopal_sdl.rb の修正

  • 同時に複数の効果音が再生できるようにした
  • 効果音を再生時にロードするのをやめ、最初にまとめてロードするようにした

これはリファクタリングではありません。 どちらも切実に困っていたわけではないですが、直し方が分かったので直した、思いついたので直した、という感じの修正です。



kairo-gokko (18) 回路の分割 2



子回路へ分割の実装編です。

修正前はこうなっていたのを、

# class ChildCircuit

  def self.create(lines, rects)
    all_plus_poles = ...
    all_minus_poles = ...
    all_switches = ...

    wf_set = to_wire_fragments(lines)
    all_edges = to_edges(wf_set)

    ChildCircuit.new(
      all_edges,
      all_plus_poles,
      all_minus_poles,
      all_switches
    )
  end

次のように変更しました。

  def self.create(lines, rects)
    all_plus_poles = ...
    all_minus_poles = ...
    all_switches = ...

    wf_set = to_wire_fragments(lines)
    all_edges = to_edges(wf_set)

    edge_groups = to_edge_groups(all_edges)

    edge_groups.map { |edges|
      plus_poles  = select_child_circuit_units(edges, all_plus_poles)
      minus_poles = select_child_circuit_units(edges, all_minus_poles)
      switches    = select_child_circuit_units(edges, all_switches)

      ChildCircuit.new(
        edges,
        plus_poles,
        minus_poles,
        switches
      )
    }
  end
  • to_edge_groups() でエッジを子回路ごとにグループ化し、
  • グループごとに
    • select_child_circuit_units() でグループ内のエッジ上にあるプラス極、マイナス極、スイッチだけを選び、
    • edges と一緒に ChildCircuit.new に渡してインスタンスを生成

to_edge_groups() は何をしているかというのが下記です。 ここが分割処理のメインの部分。

  def self.to_edge_groups(all_edges)
    ecs = all_edges.map { |edge| EdgeCluster.new([edge]) }

    loop do
      merge_occured = false

      ecs.combination(2).each { |ec_a, ec_b|
        if ec_a.connected_to?(ec_b)
          ec_a.merge(ec_b)
          merge_occured = true
        end
      }

      break unless merge_occured

      ecs = ecs.reject { |ec| ec.edges.empty? }
    end

    ecs.map { |ec| ec.edges }
  end

EdgeCluster はエッジの配列と便利メソッドを持つ作業用のクラスです。

  • EdgeCluster 1つにつき 1本のエッジを持たせた状態から開始し、
  • EdgeCluster 同士のすべての組み合わせについてエッジのいずれかがつながっているかを調べ、

    • つながっていたら片方の EdgeCluster が持っているエッジの配列をもう片方にマージ(移動)する。
  • マージが発生しなくなるまで繰り返す

小さなクラスタ同士をちょっとずつくっつけていって、それ以上くっつかなくなったら終わり、みたいなイメージですね。オレオレなのでもっと良い方法があるかもしれません。

最初は組み合わせの列挙をベタに二重ループで書いていましたが、 Array#combination で書けると気づいて書き直しました。便利。

Array#combination (Ruby 2.7.0 リファレンスマニュアル)
https://docs.ruby-lang.org/ja/latest/method/Array/i/combination.html


2つのエッジがつながっているかどうかを判断するにはノードが重なっているかを見てあげれば OK。

# class EdgeCluster

    def connected_to?(other)
      all_node_set.intersect?(other.all_node_set)
    end

以上で子回路ごとに分割する処理ができました。 あとは、 ChildCircuit.create が単体の ChildCircuit ではなく ChildCircuit の配列を返すようになったので、それに合わせて preprocess.rbmain.rb を修正すればできあがりです。


f:id:sonota88:20200308170322g:plain

大丈夫ですね、動いてますね!


以下の iframe で実際に試せます (※マウス操作のできないスマホタブレットは非対応)。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/18/index.html


ちなみに、「つながっている」「つながっていない」と適当に言ってきましたが、グラフ理論の用語では「連結」「非連結」と言うそうです。

今回の分割処理は、「非連結な1つのグラフから複数の連結なグラフへの変換」と言える……んでしょうか?(この表現で合ってます?)。 間違ってたらごめんなさい。

(2021-05-26 追記: ここで子回路と呼んでいるものはグラフ理論の用語では「連結成分」に相当するようです)



kairo-gokko (17) 回路の分割 1



通電判定の説明で使った図を 2つ並べてみました。 たとえば 1枚の基板上(机の上とかでもいいですが)にこういうふうに並べて配置されている状況を想像してみます。

f:id:sonota88:20200308110730p:plain

エッジについて通電判定するためにダイクストラ法を使って最短距離を求めることにしましたが、このとき、左の島について判定する際に右の島の情報(エッジとノード)も使ってしまうと、無駄な処理が発生してしまいます。

というのも、左右の島同士は完全に切り離されていて、 右の島の状態によって左の島の挙動が変わることはないはずだからです(左右を逆にしても同じ)。

なので、これは分割統治するのが良いでしょうね。


今の時点ではパフォーマンス上の問題(処理落ちなど)はまだ出ていませんから、 その点では先回り気味だと思います。 それよりも、分割統治により扱うデータの範囲・量が狭まって一度に考慮すべき事項が減ることの方がありがたい感じです。


修正の前に名前を整理します。

とりあえず、記事上では、区別する必要がある場合は次のように呼ぶことにします。

  • 子回路: 上記で「島」と呼んでいたものに相当。
  • 回路全体: 上記で「基板」と呼んでいたものに相当。1個以上の子回路を含む。

それはいいとしてクラス名をどうするか……いろいろ悩みましたが *1、ひとまず下記で進めます。

  • ChildCircuit: 子回路に相当
  • Circuit: 回路全体に相当

ということは、まずは今までの Circuit クラスを ChildCircuit にリネームして、 変数名やファイル名も合わせて修正して……今回は力尽きたのでここまで。



*1:ChildCircuit は長くて嫌だけど回路全体は CircuitSet とか WholeCircuit ではなく Circuit と呼びたいだとか、これまでの説明との連続性がとか、いっそのこと board, island ではどうかとか……

kairo-gokko (16) 通電 7



前回やや強引に通電判定エンジンができたということにしましたので、 アプリケーション本体に組み込みます。 ちゃんと動いてくれるでしょうか?


エッジが 1本、 4本以外のときは未対応として例外を投げていましたが、 ここを update_many_edges() を呼ぶように変更。

  def update_edges
    case @edges.size
    when 1
      is_tuden = Tuden.tuden?(@switches)
      @edges[0].update(is_tuden)
    when 4
      update_4_edges()
    else
      update_many_edges() # ここ
    end
  end

update_many_edges() はこんな感じ。

  def update_many_edges
    tnodes, pos_nid_map, nid_plus, nid_minus =
      prepare_tuden_nodes()

    tedges, eid_edge_map =
      prepare_tuden_edges(pos_nid_map)

    Tuden.update(
      tedges,
      tnodes,
      nid_plus,
      nid_minus
    )

    tedges.each { |tedge|
      edge = eid_edge_map[tedge.id]
      edge.update(tedge.on?)
    }
  end
  • (1) エッジID、ノードIDを採番しながら Unit::Edge から 通電判定処理用の tedges, tnodes を生成 (prepare_tuden_nodes(), prepare_tuden_edges()

  • (2) 通電判定処理を実行

  • (3) 判定の結果を Circuit#edges に反映させる

という流れです。

Tuden 内では座標について考える必要をなくし、 純粋なグラフのデータとして扱うために Tuden::EdgeTuden::Node を使っていました。 そのインターフェイスに合わせるためのアダプタ部分が (1) と (3) ですね。


f:id:sonota88:20200308055053p:plain

試しにたくさんの枝分かれとスイッチがあるこんな回路を描いてみました。


さて……



動くでしょうか!?




f:id:sonota88:20200307145544g:plain

それっぽく動いてるように見えます!! 🎉🎉🎉🎉

前回も書いたように通電判定処理はほとんど最適化していませんが、 このくらいの規模なら特に問題ないようです。


以下の iframe で実際に試せます (※マウス操作のできないスマホタブレットは非対応)。

こちらも同じものです。
https://sonota88.github.io/kairo-gokko/pages/16/index.html