Rubyのarray.eachの中ではarray.deleteを使うべからず
「Rubyでポン」の制作でバグを作ってしまった話を書きます。おおっ、技術者ブログっぽい!
Rubyでポンとは(というか、パネルでポンとは)
「パネルでポン」を知らない方も多いと思うのでかるーく紹介。20年前にSFCで出たパズルゲームです。
基本的には「ぷよぷよ」に代表される落ちものパズルのように、パネルの色を揃えて消していくゲームになります。色々ユニークな特徴はあるのですが今回は省略。とりあえずプレイ動画を貼っておきます。
SFCパネルでポン 1人で最高レベル同士ガチンコ対戦 - YouTube
そして、「Rubyでポン」はそんな「パネルでポン」のクローンゲームです。Rubyで作ってます。
コード内容
バグってたときに書いてたコードを、思いっきり単純化したものです。配列panelsにそれぞれのパネルが格納されていて、消える条件を満たしていたら配列から削除する、というようなコードを書いていました。
panels.each { |panel| if panel.to_vanish? panels.delete(panel) ... # panelが消える時だけ行う、他の操作 end }
このコードを動かすと、消えるはずの一部のパネルが消えてくれない。色々調べてると以下の記事にあたりました。
【Ruby】配列の複数要素の削除はdelete_ifかrejectを使おう | one's world
これだ!
記事中ですごく丁寧な実験例を出されていますが、今回のコードだと
- panelsを順にイテレートしていて、例えばpanels[4]が消えるとする
- panels[4]がdeleteされた時点で、隙間が詰められる。panels[5]だったものがpanels[4]に、panels[6]だったものがpanels[5]になる
- 次に見にいくのは「この時点での」panels[5]
- なので、「panels[5]だったけどさっきpanels[4]になった」要素はすり抜けてしまう!
っていうことが起こってしまうのですね。
そもそもdelete_ifを使わなかった理由が、パネルが消える時、消えるパネルを配列から除く以外の操作も一緒にやりたかったからなんです。しかし、例えば以下のようにすれば、安全にやりたいことができますね。
panels.delete_if { |panel| if panel.to_vanish? ... # panelが消える時だけ行う、他の操作 true else false end }
余談
現在の実装はそもそもパネルを上記のように格納してなくて、縦×横の位置をindexとした2重配列として格納しています。パネルが存在しない地点の要素はnil。なので、以下のようなコードになります。これだと、そもそも上記のような問題を気にすることもないですね…
(0...X) { |x| (0...Y) { |y| if panels[x][y].to_vanish? ... # panels[x][y]が消える時だけ行う、他の操作 panels[x][y] = nil end } }