SVX日記
2025-05-01(Thu) デスクトップでF1のラップタイム計測ごっこ
ラップタイムの計測をしたいなぁ、ということで、実装してみることにした。
計測には、いわゆる「当たり判定」が必要になる。以前に、2Dシューティングにおける「箱同士」の当たり判定は書いたことがあるのだが、同じ2Dでも任意の角度に回転する物体同士の当たり判定には別の方法が必要になる。
確かベクトル演算の手法が使えたような。ちょっと前に読んだ線形代数の本を引っ張り出してきて調べる。そうだった、外積だ。座標pが、座標a, bを通る直線のどちら側に位置するのかを判定できる。今回は、コース上のラップ計測ラインの通過を判定したいだけだから、それ一発で済む。まずは、理解するためのサンプルコードを書く。
#!/usr/bin/env ruby
# 座標 p が、座標 a, b を通る直線のどちら側に位置するかを調べる
def vec(i, j) # 座標 i->j をベクトル化
{ :x => j[:x] - i[:x],
:y => j[:y] - i[:y] }
end
def vcross(i, j) # ベクトル i, j の外積を求める
i[:x] * j[:y] - i[:y] * j[:x]
end
a = { :x => 5, :y => 3 } # 座標 a
b = { :x => 15, :y => 10 } # 座標 b
ab = vec(a, b) # 直線ベクトル a->b
20.times {|y|
20.times {|x|
p = { :x => x, :y => y } # 座標 p
ap = vec(a, p)
print(vcross(ab, ap) < 0 ? ' -' : ' +')
}
puts
}
+ - - - - - - - - - - - - - - - - - - -
+ + + - - - - - - - - - - - - - - - - -
+ + + + - - - - - - - - - - - - - - - -
+ + + + + a - - - - - - - - - - - - - -
+ + + + + + + - - - - - - - - - - - - -
+ + + + + + + + - - - - - - - - - - - -
+ + + + + + + + + + - - - - - - - - - -
+ + + + + + + + + + + - - - - - - - - -
+ + + + + + + + + + + + + - - - - - - -
+ + + + + + + + + + + + + + - - - - - -
+ + + + + + + + + + + + + + + b - - - -
+ + + + + + + + + + + + + + + + + - - -
+ + + + + + + + + + + + + + + + + + - -
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
判定処理は毎フレームしこたま繰り返すので、計算を軽くしつつ、オブジェクト化して書き直す。a→bベクトルは一度計算するだけでいいので、TimingLineクラスの初期化時に行うようにし、判定をover?メソッドにまとめる。より、直感的な記述になりつつ、同じ実行結果が得られる。
#!/usr/bin/env ruby
# 座標 p が、座標 a, b を通る直線のどちら側に位置するかを調べる
class TimingLine
def initialize(a, b)
@a = a
@ab = { :x => b[:x] - a[:x],
:y => b[:y] - a[:y] }
end
def over?(p)
@ab[:x] * (p[:y] - @a[:y]) - @ab[:y] * (p[:x] - @a[:x]) < 0
end
end
a = { :x => 5, :y => 3 } # 座標 a
b = { :x => 15, :y => 10 } # 座標 b
tline = TimingLine.new(a, b)
20.times {|y|
20.times {|x|
p = { :x => x, :y => y } # 座標 p
print(tline.over?(p) ? ' -' : ' +')
}
puts
}
思索しつつ試作したコードを、CoffeeScriptで書かれたゲームのコードに落とし込む。それなりにオブジェクト化してあるので、どのオブジェクトに、どう落とし込むか、非常に考えどころである。とりあえず、地球上の位置(Wpos)クラス、コース(Course)クラス、メインプログラムに落とし込んでみた。
$ diff ../topdrivin.org/wpos.bean wpos.bean
85a86,92
> to_vec: (wpos) -> # 終点座標を渡し、ベクトル情報を生成
> @vec_x = wpos.wpx - @wpx
> @vec_y = wpos.wpy - @wpy
>
> vec_over: (wpos) -> # 座標を渡し、ベクトル線を超えたか返す
> @vec_x * (wpos.wpy - @wpy) - @vec_y * (wpos.wpx - @wpx) < 0
>
$ diff ../topdrivin.org/course.bean course.bean
19c19
< constructor: (_s, x, y, car) ->
---
> constructor: (_s, x, y, car, sectors) ->
21a22
> @sectors = sectors
54a56,58
> @sector = 0 # 開始セクタ(ラップタイム計測)
> @dir = _s['DIRECTOR']
>
80a85,91
> # ラップタイム描画
> @context.font = '24px sans-serif'
> @context.fillStyle = 'white'
> @context.textAlign = 'right'
> @context.fillText(@sectors[@sector]['name'], 100, 24)
> @context.fillText(@dir.tsc1000, 100, 48)
>
95a107,108
> if(@sectors[@sector]['vec'].vec_over(@car.wpos)) # セクタ計測ラインを超えた?
> @sector = (@sector + 1) % @sectors.length
$ diff ../topdrivin.org/sample_course_view.bean sample_course_view.bean
80a81,88
> sectors = [
> { l: [25.957223, -80.244210], r: [25.957362, -80.244212], name: 'dummy 05' },
> { l: [25.956204, -80.243633], r: [25.956089, -80.243657], name: 'sector 1' },
> { l: [25.958981, -80.229886], r: [25.958922, -80.229795], name: 'dummy 15' },
> { l: [25.960090, -80.230708], r: [25.960203, -80.230716], name: 'sector 2' },
> { l: [25.960523, -80.242873], r: [25.960578, -80.243020], name: 'dummy 25' },
> { l: [25.959922, -80.238718], r: [25.959788, -80.238811], name: 'finish line' },
> ]
83a92,96
> @tsc1000 = 0; @tsc1000inc = [17, 16, 17] # 1/1000時計を初期化
> for sector in sectors
> sector['vec'] = Wpos.deg(sector['l'][1], sector['l'][0])
> sector['vec'].to_vec(Wpos.deg(sector['r'][1], sector['r'][0]))
>
87c100
< @objs['COURSE'].push(new Course(@_s, 0, 0, mycar))
---
> @objs['COURSE'].push(new Course(@_s, 0, 0, mycar, sectors))
95a109
> @tsc1000 += @tsc1000inc[tsc % 3] # 1/1000時計を加算
マイアミサーキットの場合、各セクタの計測ラインが、いずれもUターンのようなコーナーの先にあるため、計測ラインの手前にダミーの計測ラインを設けている。そうしないと、各セクタの計測ラインを超える前に、超えたという判定になってしまうためである。いったんアッチに行ってからね、って感じ。
ちなみに、計測に使うタイムは、ゲームの固定FPS(1/60タイマ)に同期する仕様とした。ただし、1/60秒は割り切れない値なので、1000分の17, 16, 17を順に加算することで作り出している。これは逆に言うと、60FPSのゲームなので16/1000秒以下の計測粒度はない、ということなのだが、サウジアラビアの予選でポールのフェルスタッペン、ピアストリのタイム差は10/1000秒。それは、優に格ゲーの1フレーム以下の戦いだったってことである。マジかよ……。
それにしても、今回、最終的に追加したコードは意外なほど少ない。実は丸2日かかっているのだけれど。というのも、上述したように「どのオブジェクトに、どう落とし込むか」が非常に考えどころであり、楽しいところでもあるのである。「ラップタイム計測」をするのは誰(どのオブジェクト)であるべきか? 自車(MyCar)クラスか? 今回はコース(Course)クラスに追加したが、それで正しいのだろうか。今回は、ラップタイムの描画もコース(Course)クラスにやらせているが、ラップタイムの管理も含めて、ラップタイムクラスを新設するべきだし、それを駆動するのは自車(MyCar)クラスであるべきのようにも思える。
前にも書いたが、やっぱりプログラミングは盆栽だなぁ、と思う。処理をどこに足すべきか。それは、どの枝を伸ばすか、みたいなものなのではないか。間違ったら剪定して、また違う枝を伸ばしてみたり。