Goで車ゲームのメーターを作ってみた
Codemastersの車ゲーム全般には「テレメトリ情報」をネットワーク経由で出力する機能があります。自動車にはOBD-2と呼ばれるCANベースの車載システム診断プロトコルがありますがそれに近しいものと考えられます。
「テレメトリ情報」に車ゲームならではの情報を含みます。
- 車速
- ハンドルの舵角
- ペダル類の踏み込み量
- 変速機の状態
- エンジンの回転数
- 3次元座標、速度、姿勢
- ABS,トラクション制御
- REVリミット
- その他
これらのうち、動画実況にあるとよい以下のようなプレイヤーの操作量の表示だけを実装しました。
- ハンドルの舵角
- ペダル類の踏み込み量
- 変速機の状態
成果物:
表示イメージ:
中央のNは「R,N,1,2,3,4,5,6」というように変速機の状態を表示し、その周囲にあるサークルはハンドルの舵角を表示します。
縦のバーは左からクラッチ、ブレーキ、スロットルです。
テレメトリ出力プロトコル
DiRT Rally 2.0というタイトルにて設定を調整してタイトルを終了すると、
"%USERPROFILE%\Documents\My Games\DiRT Rally 2.0\hardwaresettings\hardware_settings_config.xml"
というファイルが作られますが、
その内容のうち、以下の項目を変更すると、
before:
<udp enabled="false" extradata="0" ip="127.0.0.1" port="20777" delay="1" />
after:
<udp enabled="true" extradata="3" ip="127.0.0.1" port="20777" delay="1" />
UDPパケットにバイナリ形式で各種パラメータが詰め込まれて、「指定したアドレス:ポート」に変更があるたびに投げこまれるというものです。
そのバイナリフォーマットはCodemastersが公開してるので、いろんなアクセサリメーカーがそのプロトコルを受けて動くコクピットなどが製品化されたりするわけです。
なぜか、このパケットのデコードパッケージがGitHubを検索したらGoで実装したものが見つかり、それを利用させてもらいました!
GUIについて
ここで紹介した「Wails v2」を使いました。
プロジェクトの立ち上げ
wails init -n dr2telemetry -t https://github.com/nobonobo/wails-sveltekit
cd dr2telemetry
上記テンプレートの状態から主に編集したファイルは以下の2つ
- frontend/src/routes/index.svelte
- app.go
SvelteKitでSVG表示
frontend/src/routes/index.svelteが最初に表示される画面になります。
<script>
タグには動的要素の更新処理を記述、<svg>
タグにはInkscapeでSVGを作図したものをコピペして、動的要素の属性をSvelteパラメータに差し替えました。
Wailsのイベントハンドラ「runtime.EventsOn」にて「telemetry」という名称の自作イベントの処理を記述します。ここでは受け取ったパラメータ群から、Svelteのパラメータ群へ反映させている処理を記述しておきます。Svelteのパラメータは更新されるとリアクティブにフロントエンドの更新を行ってくれます。
<script>
let Gear = "N";
let Clutch = 100.0;
let Brake = 100.0;
let Throttle = 100.0;
let Steer = 0;
runtime.EventsOn("telemetry", (data, deg) => {
//console.log(data);
if (data.Gear == 0) {
Gear = "N";
} else if (data.Gear < 0) {
Gear = "R";
} else {
Gear = data.Gear.toString(10);
}
Clutch = 100 * (1 - data.Clutch);
Brake = 100 * (1 - data.Brake);
Throttle = 100 * (1 - data.Throttle);
Steer = (data.Steer * deg) / 2;
});
window.addEventListener("mousemove", () => {
window.runtime.EventsEmit("window-activate", true);
});
</script>
<svg
width="100%"
height="100%"
viewBox="0 0 230 120"
version="1.1"
id="svg5"
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
sodipodi:docname="frame.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
>
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="false"
showguides="true"
inkscape:zoom="0.89449008"
inkscape:cx="187.8165"
inkscape:cy="183.34468"
inkscape:window-width="1251"
inkscape:window-height="637"
inkscape:window-x="234"
inkscape:window-y="234"
inkscape:window-maximized="0"
inkscape:current-layer="layer1"
inkscape:lockguides="true"
/>
<g id="layer1">
<g id="g995" transform="translate(0,-5)">
<rect
style="fill:#d7d71f;fill-opacity:1;stroke:none;stroke-width:0.999934"
id="Clutch"
width="20"
height="100"
x="20"
y="15"
/>
<rect
style="fill:#407eb6;fill-opacity:1;stroke:none;stroke-width:0.999999"
id="ClutchInvert"
width="20"
height={Clutch}
x="20"
y="15"
/>
</g>
<g id="g1001" transform="translate(30,-5)">
<rect
style="fill:#db1b1b;fill-opacity:1;stroke:none;stroke-width:0.999934"
id="Brake"
width="20"
height="100"
x="20"
y="15"
/>
<rect
style="fill:#407eb6;fill-opacity:1;stroke:none;stroke-width:1"
id="BrakeInvert"
width="20"
height={Brake}
x="20"
y="15"
/>
</g>
<g
id="Steer"
transform="rotate({Steer},129.9997565,60) translate(-9.9997565)"
>
<path
id="path1055"
style="fill:#35d422;fill-opacity:1;stroke:none"
d="m 140,10 a 50,50 0 0 0 -50,50 50,50 0 0 0 50,50 50,50 0 0 0 50,-50 50,50 0 0 0 -50,-50 z m 0,20 a 30,30 0 0 1 30,30 30,30 0 0 1 -30,30 30,30 0 0 1 -30,-30 30,30 0 0 1 30,-30 z"
/>
<path
id="circle1057"
style="fill:#407eb6;fill-opacity:1;stroke:none"
d="M -11.703125,127.05859 A 50,50 0 0 0 -66.527344,90.427734 50,50 0 0 0 -110,140 a 50,50 0 0 0 43.472656,49.57227 50,50 0 0 0 54.824219,-36.63086 L -31.03125,147.76172 A 30,30 0 0 1 -60,170 a 30,30 0 0 1 -30,-30 30,30 0 0 1 30,-30 30,30 0 0 1 28.867188,22.26562 z"
transform="rotate(-90)"
/>
</g>
<g id="g1846" transform="translate(170,-5)">
<rect
style="fill:#0aebec;fill-opacity:1;stroke:none;stroke-width:0.999934"
id="Throttle"
width="20"
height="100"
x="20"
y="15"
/>
<rect
style="fill:#407eb6;fill-opacity:1;stroke:none;stroke-width:0.999997"
id="ThrottleInvert"
width="20"
height={Throttle}
x="20"
y="15"
/>
</g>
<text
xml:space="preserve"
style="font-size:64px;text-align:center;text-anchor:middle;fill:#2f8bf0;fill-opacity:1;stroke:none;pointer-events:none;"
x="129.93774"
y="83.265625"
id="text3042"
><tspan id="Gear" x="129.93774" y="83.265625">{Gear}</tspan></text
>
</g>
</svg>
SvelteはSVGツリーを該当箇所だけを更新してくれるので動作が軽快です。
細かい点
- 一定時間後バックグラウンドに隠れるという挙動を実装したいのだけど、Windowに触れている間は表示に用事があるということなので隠さないようにするためにマウスの移動を検知したら操作中なことをバックエンド側に伝えるための自作イベントを発行します。
- Windowリサイズに対応するため、SVGのwidthとheightは100%に変更。
- ウインドウの最小化・復元で最初は実装したんだけど、これだと復元の時にフォーカスまで奪い取っちゃうみたいで、今回の用途に合わなかった。Show/Hideを使うと初回以外ではフォーカスを奪わなくなりました。
- バックエンドとフロントエンド間で互いのメソッドなどを呼べる仕掛けがあったんですが、依存解決がちょっと追いきれなくてカスタムイベントを送りあう形で解決しました。
主なバックエンド実装
機能
- JSONファイル読み込んで保存したウインドウの場所やサイズを復元する。
- 15秒タイマーでウインドウを隠し、ウインドウのジオメトリをJSONファイルに保存する。
- ウインドウ上でマウスカーソルが動くか、テレメトリUDPパケットが受信されたとき、タイマーはリセットされ、ウインドウは表示される。
- テレメトリUDPパケットの内容をパースした結果をフロントエンドへ通知する。
コード
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"time"
"github.com/jake-dog/opensimdash/codemasters"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// Config struct
type Config struct {
Port int `json:"port"`
Lock2Lock int `json:"lock2lock"`
WindowX int `json:"window_x"`
WindowY int `json:"window_y"`
WindowW int `json:"window_w"`
WindowH int `json:"window_h"`
}
// App struct
type App struct {
ctx context.Context
show chan bool
config *Config
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{show: make(chan bool)}
}
func (a *App) Kick() {
a.show <- true
}
func (a *App) handle(ctx context.Context, c *net.UDPConn) {
b := make([]byte, 4096)
for {
select {
case <-ctx.Done():
return
default:
}
n, err := c.Read(b)
if err != nil {
log.Fatal(err)
}
packet := b[:n]
var pkt codemasters.DirtPacket
pkt.Decode(packet)
runtime.EventsEmit(ctx, "telemetry", pkt, a.config.Lock2Lock)
a.Kick()
log.Printf("%#v", pkt)
}
}
func (a *App) configPath() string {
self, err := os.Executable()
if err != nil {
return "params.json"
}
return filepath.Join(filepath.Dir(self), "params.json")
}
func (a *App) Load(ctx context.Context) (*Config, error) {
fp, err := os.Open(a.configPath())
if err != nil {
return nil, err
}
defer fp.Close()
var conf *Config
if err := json.NewDecoder(fp).Decode(&conf); err != nil {
return nil, err
}
return conf, nil
}
func (a *App) Save(ctx context.Context) (err error) {
conf, err := a.Load(ctx)
if err != nil {
conf = &Config{
Port: 20777,
Lock2Lock: 900,
WindowX: 880,
WindowY: 540,
WindowW: 230,
WindowH: 120,
}
} else {
if err := os.Rename(a.configPath(), a.configPath()+".bak"); err != nil {
return err
}
}
conf.WindowX, conf.WindowY = runtime.WindowGetPosition(ctx)
conf.WindowW, conf.WindowH = runtime.WindowGetSize(ctx)
fp, err := os.Create(a.configPath())
if err != nil {
return err
}
defer func() {
if err != nil {
if _, e := os.Stat(a.configPath()); os.IsExist(e) {
os.Remove(a.configPath())
}
if _, e := os.Stat(a.configPath() + ".bak"); os.IsExist(e) {
os.Rename(a.configPath()+".bak", a.configPath())
}
}
}()
defer fp.Close()
b, err := json.MarshalIndent(conf, "", " ")
if _, err := fp.Write(b); err != nil {
return err
}
if err := fp.Sync(); err != nil {
return err
}
return nil
}
// startup is called at application startup
func (a *App) startup(ctx context.Context) {
// Perform your setup here
a.ctx = ctx
conf, err := a.Load(ctx)
if err != nil {
log.Print(err)
return
}
a.config = conf
udpAddr := &net.UDPAddr{
IP: net.ParseIP("localhost"),
Port: a.config.Port,
}
c, err := net.ListenUDP("udp", udpAddr)
if err != nil {
log.Fatal(err)
}
runtime.EventsOn(ctx, "window-activate", func(optionalData ...interface{}) {
a.Kick()
})
go a.handle(ctx, c)
go func() {
tm := time.NewTimer(15 * time.Second)
for {
select {
case <-ctx.Done():
return
case v := <-a.show:
if v {
tm.Reset(15 * time.Second)
runtime.WindowShow(ctx)
}
case <-tm.C:
if err := a.Save(ctx); err != nil {
log.Print(err)
}
runtime.WindowHide(ctx)
}
}
}()
runtime.WindowSetPosition(ctx, a.config.WindowX, a.config.WindowY)
runtime.WindowSetSize(ctx, a.config.WindowW, a.config.WindowH)
}
// domReady is called after the front-end dom has been loaded
func (a App) domReady(ctx context.Context) {
// Add your action here
}
// shutdown is called at application termination
func (a *App) shutdown(ctx context.Context) {
// Perform your teardown here
}
Windowの属性調整
main.go
にあるWindows向け専用の属性2つをtrueにします。
WebviewIsTransparent: true,
WindowIsTranslucent: true,
これですりガラスのような透過ウインドウになります。
また、以下のフラグも変更します。
Frameless: true,
AlwaysOnTop: true,
これでフルスクリーンアプリの上に表示され、ウインドウの枠がないアプリになります。
ソースからビルド
from Windows to Windows
winget install GoLang.Go
go install github.com/wailsapp/wails/v2/cmd/wails@latest
git clone https://github.com/nobonobo/dr2telemetry
cd dr2telemetry
wails build
from macOS to Windows
brew install go
go install github.com/wailsapp/wails/v2/cmd/wails@latest
git clone https://github.com/nobonobo/dr2telemetry
cd dr2telemetry
wails build -target windows
from Ubuntu22.04 to Windows
sudo apt install golang
go install github.com/wailsapp/wails/v2/cmd/wails@latest
git clone https://github.com/nobonobo/dr2telemetry
cd dr2telemetry
wails build -target windows
Windowsで動かすのに必要なランタイム
結果
あらかじめ起動しておくと、パケットが届くと表示される。
ペダルやハンドル操作が表示されている様子。
そして、走行が終了すると数秒後にシュッと消えます。
- この状態で動画配信をすると初心者のかたの参考になるというわけです。
- そしてリプレイを眺めたりするときに邪魔にならない。
続編
OBSプラグインとして再構築しました。
これはブラウザソースをOBSのシーンにに追加する事例を紹介します。
シーン名とURLだけは以下と同じにする必要があります。
- "playing":
- webcam capture(必須ではありません)
- browser: url=http://localhost:8123/
- game capture
- "replay-mode":
- browser: url=http://localhost:8123/ (link from playing)
- game capture
あと、OBSの起動オプションに「--enable-gpu」が必要です。
以上の設定にてテレメトリパケットをアプリが受け取るとOBSのシーンを「playing」に切り替えます。また、テレメトリパケットが5秒届かなかったら「replay-mode」に切り替えます。
これでプレイ中にはWebCamとテレメトリメーター表示があり、リプレイ中などはWebCamとテレメトリメーターが非表示になります。
また、「WRC Generations」が同様の機能をサポートしてきましたので対応を追加~。
だいたい互換だったんだけど、一部のパラメータが正負逆転してたのでパケット内容の違いから自動判別して対応しました。
Discussion
GRID Legends でも同様に使えました!
この記事はGo+WailsV2+SvelteKitの実例になっています。