🦜

Goで車ゲームのメーターを作ってみた

2022/07/08に公開
2

Codemastersの車ゲーム全般には「テレメトリ情報」をネットワーク経由で出力する機能があります。自動車にはOBD-2と呼ばれるCANベースの車載システム診断プロトコルがありますがそれに近しいものと考えられます。

「テレメトリ情報」に車ゲームならではの情報を含みます。

  • 車速
  • ハンドルの舵角
  • ペダル類の踏み込み量
  • 変速機の状態
  • エンジンの回転数
  • 3次元座標、速度、姿勢
  • ABS,トラクション制御
  • REVリミット
  • その他

これらのうち、動画実況にあるとよい以下のようなプレイヤーの操作量の表示だけを実装しました。

  • ハンドルの舵角
  • ペダル類の踏み込み量
  • 変速機の状態

成果物:
https://github.com/nobonobo/dr2telemetry

表示イメージ:

中央の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で実装したものが見つかり、それを利用させてもらいました!

https://github.com/jake-dog/opensimdash

GUIについて

https://zenn.dev/nobonobo/articles/6cc4c510988e82

ここで紹介した「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のパラメータは更新されるとリアクティブにフロントエンドの更新を行ってくれます。

frontend/src/routes/index.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パケットの内容をパースした結果をフロントエンドへ通知する。

コード

app.go
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で動かすのに必要なランタイム

https://developer.microsoft.com/en-us/microsoft-edge/webview2/

結果

あらかじめ起動しておくと、パケットが届くと表示される。
https://www.youtube.com/clip/Ugkx3h2xyJtpQukD4vKHRu4bgE_fjtP7AxDQ

ペダルやハンドル操作が表示されている様子。
https://www.youtube.com/clip/UgkxWFEp3nBwV08dNe6XoCL4NxH6aLH1kvZI

そして、走行が終了すると数秒後にシュッと消えます。
https://www.youtube.com/clip/Ugkxf_yn3yo4ZqNKpG3NnCSqAPUOvB38x7Uq

  • この状態で動画配信をすると初心者のかたの参考になるというわけです。
  • そしてリプレイを眺めたりするときに邪魔にならない。

続編

OBSプラグインとして再構築しました。
https://github.com/nobonobo/obs-codemasters-telemetry

これはブラウザソースを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

NoboNoboNoboNobo

この記事はGo+WailsV2+SvelteKitの実例になっています。