NaaN日記

やったこと、覚えたことを発信する場

Perlでテーブル駆動テストを書くのにハマっている

この記事は、はてなエンジニア Advent Calendar 2022 - Hatena Developer Blogの45日目の記事です。

昨日はid:cohalz さんのGitHub ActionsでJobやStepをデフォルトブランチだけ動かすようにする (2023年版) - Re:cohalzでした。
github.event.repository.default_branch、初めて知りましたが便利。

明日はid:dekokun さんのGuardDutyの検知結果をslack投稿するアーキテクチャを考えた(あるいはAWSでイベントのフィルタを行うときに考えたこと) - でこてっくろぐ ねおです。


テーブル駆動テスト、使っていますか。
大体同じようなことをテストするけど、

  1. AとBでちょっとだけ違う処理をしたい
  2. AとBで結果を変えたい

といった時に、テーブル駆動テストの書き方は便利です。

CNaanがテーブル駆動テストを知ったのは、今から約4ヶ月前で、id:papix さんが業務でリファクタリングのPullRequestを作成していて、そのPRをレビューしたときに「こういう書き方があるのか」と知りました。
id:papix さんはよくflaky testの修正やリファクタリングPRを作成しています。
姿勢を見習っていきたい。
それ以降、使えそうなところではできるだけテーブル駆動の書き方で書いてみることを心がけています。

私が思うテーブル駆動テストを書くことのメリット

1. リポジトリ内でテーブル駆動テストを使っている例を見つけやすい

大体下記の流れで書くので、$cases =とかで検索すると、他の人がどういうテストを書いているかが見つけやすい。
似たような書き方になるので統一感もある気がする。

my $cases = [
    +{
        description => '〇〇のとき',
        mock => sub {
            # サブルーチンも使える
        },
        expected => 1,
    },
    (略)
];

for my $case (@$cases) {
    subtest $case->{description} => sub {
        # 特定のcaseのみ実行する処理はcase経由で呼ぶ
        $case->{mock}->();
        # 全てのcaseで実行する処理
        sample();
        (略)
        is, $result, $case->{expected};
    };
}

done_testing();

2. テストの冒頭で何をテストするかがまとまっていて見やすい

$cases = []の中を読めばこれから何のテストをやっているかがわかる。
テーブル駆動(ループ)を使わずコピー&ペーストでテストを書いていたら、上から下まで見ていく必要がある。
コピー&ペーストだと可読性が下がるし、一つの変更をする時に複数箇所変更する必要があって、変更し忘れなどの漏れが起きやすい気がする。

3. 新しいケースを追加するとき、簡単に追加できる

$casesに差を追加するだけで、増やせるし、減らしたい時はコメントアウトすれば良い。

4. 似た処理の流れをまとめられる

既に述べたメリットと似ているけど、やはり似た処理をまとめられるのが便利。
業務で書くテストでも、ユーザーを作って、とかしているけれど、それをテストケースの数だけコピー&ペーストで書くのは、後々見返した時に見るのが辛い。
一つのサブルーチンに対してのテストで複数の条件分岐が発生しそうなときは、subtest; subtest; subtest;とするのではなく、for my $case (@$cases){subtest;}とテーブル駆動テストを使ってまとめるようにしたい。

なんとなく苦手意識があるテストファイルの行数を数えると1800行くらいでした。私は2000行近くなってくると苦手意識が生まれるようです。
したがって、条件を網羅しつつ、シンプルにテストを書けることが理想です。

おわり

さて、今はテーブル駆動テストな書き方にハマってますが、読者の好きな書き方を布教して頂けると嬉しいです。
CNaanのこれからの活躍にご期待ください。


こんな感じで書いている、という例を用意しようと思ったけど微妙な例になってしまったのでここで供養。

use strict;
use warnings;
use utf8;

use Test2::V0;

my $cases = [
    +{
        description => 'ゲストユーザーのとき',
        create_user => sub {
            create_test_user();
        },
        is_guest => 1,
    },
    +{
        description => 'ログインユーザーのとき',
        create_user => sub {
            create_test_user('test');
        },
        is_guest => 0,
    },
];

for my $case (@$cases) {
    subtest $case->{description} => sub {
        my $user = $case->{create_user}->();
        is $user->{is_guest}, $case->{is_guest};
    };
}

sub create_test_user {
    my ($name) = @_;

    if ( defined $name ) {
        return +{
            name     => $name,
            is_guest => 0,
        };
    }
    return +{
        name     => undef,
        is_guest => 1
    };
}

done_testing();

$ prove sample.t

All tests successful.
Files=1, Tests=2,  1 wallclock secs ( 0.00 usr  0.00 sys +  0.05 cusr  0.01 csys =  0.06 CPU)
Result: PASS

平日休暇をとったときに、スマートフォンのSlackからサインアウトしてみた

こんにちは、はじめて源泉徴収票を貰い、社会保険料をこんなに支払ったんだなと思ったCNaanです。
入社してもう9ヶ月とかになろうとしているのか、とびっくりしています。

今日はタイトルにもある通り、平日の有給休暇でSlackからサインアウトする試みをしたことの感想です。
CNaanは休みの日でもSlackに未読があるとちょっと気になって読んでしまうタイプです。
ということで、試しにサインアウトしました。

つい癖でSlack見てしまいませんか

弊社は雑談チャンネルとかもたくさんあって、仕事の話、趣味の話、色々な話題が流れています。

つい、何か新しい話で盛り上がってないかなー、とか、同じチームの人たち元気だろうか、とか、streamチャンネル (Scrapboxや、GitHubでの議論が流れてくる場所) に面白い話題が流れていないだろうかとか、
手癖でSlackを開いてしまうことがあるんですよね。

そこで、休みの日にもSlackを見てしまうのはどうなんだろう、という話をチームでしていると、
「サインアウトしてみると良いのでは」という助言を貰いました。

※ 通知オフというのもありますが、通知オフだとSlackを見ようと思えば見れてしまうのでこれは選択肢から外しました。

というわけで、重要な用事があったら電話して下さい、と周囲に伝えた上で、サインアウトして休日を過ごすことにしました。
前日から、翌日の休暇の日にCNaanが実装したものがリリースされないように気をつけました(リリースするものは無かったけど)。

結果どうだったか

Good

  • 会社のことを考えなくて済んだ
  • スマートフォンを見る時間が減った(たぶん)
  • Slackを見ていた時間を他のことに使えた

仕事が気になって悩む、ということがない一日を過ごせました。

Bad

  • メンションが来ても気づけない
    • 他の人がCNaanさん今日居ないですよってフォローしてくださっていたのでサインインしてから返信した
    • まあこれはアンインストールではなく通知オフでも気付けないし、重要だったら電話くるはずなのでそんなにBadではない
  • 出していたPRのレビューコメント通知のメールが来てしまった

次回の挑戦

体験としては結構良かったので、時々いろいろ試してみようと思います。

新卒で入社して7ヶ月くらいが経過しました

こんにちは、id:CNaanです。

3月に大学を卒業、4月から社会人となり、半年が経ちました。
という内容で先月書いていたのを出していなかったので、7ヶ月経ってしまいました。

そろそろ入社してからを振り返ろうと思います。

  • 4月
  • 5月
    • プロジェクトに配属し、機能を少しずつ追加できるようになる
  • 8月

Perlは4月に入社してから初めて触りましたが、Rubyは触ったことがあったので、Rubyっぽいところは慣れやすかったかな、と思っています。
一方で、blessなどは中々理解できず(自分でblessを使って頑張ることには遭遇していませんが)、大変でした。

仕事でちょっと意識していたこと

小さい仕事を拾う

ちょっと空いた時間や、息抜きの時間に、非推奨となった書き方をリファクタリングしたり、ときどき落ちるテスト(Flaky Test)を直せそうだったら拾ってみたりしました。
ときどき落ちるテストの例だと、データがRedisに残っていて、そのせいでたまに落ちる、といったことがありました。
ほかだと、JSON::PP::falseはTest2::Tools::Compareで、T()とF()*1のどちらで比較してもテストにパスしてしまっていることを発見しました。JSON::PP::falseで丁寧に比較するように変更PRを出しました。
これによって、過去テスト用のサブルーチンでtypoしていて、存在しないものを参照していた(けどテストは通っていた)ことが判明したりしました(テストだけでよかった!)。

途中から入っているので、規模が大きくて完全に把握できていない箇所があったりするのですが、小さい仕事を拾うと、把握できていないところを簡単なタスクを通して見ることができるので楽しいです。

コミュニケーションでCNaanが意識していること

新卒チケットを積極的に使っていく

世間ではラストエリクサー症候群、という言葉があるらしいですが、id:CNaanは、新卒チケットは希少な消費アイテムではなくサブスクだと思うことにしています。

特に入社初期の頃などは、「多分新卒/チーム経験が浅い人視点の意見があると嬉しいかな」と思ってコメントを残す、などしていました。

最近では、障害対応などのときに「何もできませんが覚えたいのでちょっと書記しながら眺めます」というチケットの使い方をしています。(役立ってないですが新卒なので許してください!というのをもう少し続けます)

一人称をCNaanにする

これは本当にどうでもいい話ですが、一人称を自分の名前にした方が、主語がしっかりする気がしています。

id:CNaan「私はこう考えています」

id:CNaan「CNaanはこう考えています」

なんとなく、一人称を名前にしておくと、受け取り手の「私」→「CNaan」の一回分の変換作業を減らせるんじゃないかな、と思ったりもします。
あとは議事録とか取る時に、そのまま発言を転載しても誰のことかがわかりやすくて良さそう、と今思いました。
そういう理由から、自分のことを名前で呼ぶように意識しています。

おわり

こんな感じで日々を生き抜いています。
毎日楽しく働けているので、同僚の方々には感謝しています!

さいごに、入社して最初の大きなリリースである、モーニング・ツーをよろしくお願いします!
morningtwo.com
Featuredな作品が動画サムネイルにする機能を実装*2しているところがポイントです!
(※ Safariの場合省電力モードにしていると動画が再生されません)
2022/11/4現在だとピアノの森/ザ・ゲームスターズ/恋じゃねえから/会社帰りのパ・ド・ドゥの4作品の動くサムネイルを見ることができます。一回クリックして見てください!かっこいいので!!
毎週動画がカッコ良いので、週の後半の楽しみです。これを見るために仕事を頑張れている、と言っても良い。
毎週木曜正午に更新されるのでお見逃しなく!

                                    • -

最近読んだおすすめ漫画です。気になる漫画、まだ全部読めてないのでどんどん読んでいきたい!

犬好きにはたまらない読み切りです。
comic-days.com

男子中学生と母の元カノたちの関係が素敵な作品。最終話まで配信されているので、一気に読めます。
comic-days.com

最近個人的に人外医療漫画が熱いです。

comic-days.com

comic-days.com

余談ですが、Viewerの埋め込み、かっこよくて好みです。

open.talentio.com

*1:Tはtrue、Fはfalseを意味します

*2:私が機能を追加しました

初見でひっかかったPerlのmapとハッシュと配列

こんにちは。

最近業務でPerlを使い始めました、id:CNaanです。

Perl歴2ヶ月ほどとなりましたが、Perlをはじめて1ヶ月の頃に躓いた話をします。

これから話す内容は、先日Perl未経験の新卒エンジニア1名に解かせてみたところ、引っかかったので、初心者*1は引っかかりやすいポイントだと思います。

同じ引っかかりをするかな(したら共感があるな)、という気持ちを込めて、本題までゆっくりと話していきます。

やっていくぞ

まずは、こちらをご覧ください。

use strict;
use warnings;

sub get_english_to_japanese {
    return +{
        apple => 'りんご',
        phone => '電話',
    };
}

はじめにPerlが知らない人向けに言っておくと、strictとwarningsは、滅茶苦茶なソースコードの誕生を避けるために宣言すべきなものです。
これを宣言しないと、未定義の変数を突然利用できたりします。

get_english_to_japaneseは、英語と日本語の組のハッシュのリファレンスを取得するサブルーチン(関数)です。
( + は、ハッシュリファレンスであることを明示するためにつけている記号です)
大体他の言語をやっている人なら、「RubyのHashやPythonのDictみたいな感じね」という感じで雰囲気掴んでもらえるかと思います。
リファレンス とは、という話はここでは割愛します。
わからなくても問題ないですが、気になる人向けにperldoc.jpを置いておきます。
perldoc.jp
はてなキーワードもあるぞ。
リファレンスとは 一般の人気・最新記事を集めました - はてな


さて、実行すると、こんな感じで表示されます。

use DDP;
my $hashRef = &get_english_to_japanese; # &は無くても良い。サブルーチンだと分かりやすいかなと思って書いた
p $hashRef;

DDPを使うと、簡単に中身を見ることができて便利です。

% perl sample.pl
{
    apple   "りんご",
    phone   "電話"
}

printだと結果はこんな感じになります。

print $hashRef;
> HASH(0x15580aa68)

リファレンス(ハッシュ全体を参照するスカラ)になっていて、そのままでは読めませんね。
後置デリファレンスを使ってデリファレンスします。

print $hashRef->%*;    >    appleりんごphone電話

DDPを使った結果と比べると読みにくいですね。

sportsを足してみる

さて、ここでget_english_to_japaneseに他のペアも追加したいと思いました。
スポーツ系の単語を追加したいと思ったので、スポーツ系の単語をまとめたサブルーチン(sports)を作成し、get_english_to_japaneseからsportsを呼ぶことにしました。

use strict;
use warnings;

sub sports {
    return [
        {
            english  => 'soccer',
            japanese => 'サッカー',
        },
        {
            english  => 'baseball',
            japanese => '野球',
        },
    ];
}

sub get_english_to_japanese {
    my $sports = sports;

    return +{
        apple => 'りんご',
        phone => '電話',
        # ハッシュの配列を作成 ( soccer => 'サッカー', baseball => '野球' )
        map { $_->{english} => $_->{japanese} } $sports->@*,
    };
}

実行すると、このような結果が得られます。

% perl sample.pl
{
    apple      "りんご",
    baseball   "野球",
    phone      "電話",
    soccer     "サッカー"
}

やった!実装できましたね。

colorsを足してみる

この調子で、色のペアも追加していきます!

sub colors {
    return [
        {
            english  => 'red',
            japanese => '赤'
        },
        {
            english  => 'yellow',
            japanese => '黄',
        },
    ];
}
sub get_english_to_japanese {
    my $sports = sports;
    my $colors = colors;

    return +{
        apple => 'りんご',
        phone => '電話',
        map { $_->{english} => $_->{japanese} } $sports->@*,
        map { $_->{english} => $_->{japanese} } $colors->@*,
    };
}

さっきと同じノリで、sportsの後ろにcolorsを足して、実行します。

ERROR

% perl sample.pl
Can't use string ("red") as a HASH ref while "strict refs" in use at sample.pl line 37.

おや、怒られてしまいました(´・_・`)
37行目は`map { $_->{english} => $_->{japanese} } $sports->@*,`の行ですね。

mapが展開されて、↓のような結果で実行されると思ったのですが、何が問題なのでしょうか?
どうすれば意図した出力結果を得られるのでしょうか。

    return +{
        apple => 'りんご',
        phone => '電話',
        ( soccer => 'サッカー', baseball => '野球' ),
        ( red    => '赤',    yellow   => '黄' ),
    };

ところで、「おや?ハッシュの中に配列が入っている?」と違和感を感じている人向けに説明をすると、Perlでは、配列の中の配列は展開・連結される仕様となっています。

配列の中の配列?はい、PerlのHashは、文字列がキーになった配列です。

また、次の二つは同じです。
(さらに、1の書き方の場合は、キーの文字列をクォーテーションで囲まなくても大丈夫です)

1. ( apple => 'りんご', banana => 'ばなな' )
2. ( 'apple', 'りんご', 'banana', 'ばなな' )

ハッシュとして扱えばハッシュ、配列として扱えば配列になります。

{
    apple    "りんご",
    banana   "ばなな"
}
[
    [0] "apple",
    [1] "りんご",
    [2] "banana",
    [3] "ばなな"
]

ちなみに、 => のことをfat commmaと呼びます。
Perl fat comma」で調べると、有益な記事が多く出てきます。
このあたりについては、O'Reilly Japan - 初めてのPerl 第7版で、2の書き方→1の書き方の順番で丁寧に説明しています。

Deparse してみる

では、実際どうなのか?そのことを調べるために、Deparseを使います。

% perl -MO=Deparse sample.pl
sub sports {
    use warnings;
    use strict;
    return [{'english', 'soccer', 'japanese', "サッカー"}, {'english', 'baseball', 'japanese', "野球"}];
}
sub colors {
    use warnings;
    use strict;
    return [{'english', 'red', 'japanese', "赤"}, {'english', 'yellow', 'japanese', "黄"}];
}
sub get_english_to_japanese {
    use warnings;
    use strict;
    my $sports = sports();
    my $colors = colors();
    return {'apple', "りんご", 'phone', "電話", map({$_->{'english'}, $_->{'japanese'};} @$sports, map({$_->{'english'}, $_->{'japanese'};} @$colors))};
}

Deparseしたことで原因がわかりましたね。
つまり、下記のように、mapの解釈が意図と違って解釈されていた、というわけなのです。
一つ目のmapが取る配列が、二つ目のmapの結果の配列と結合しているんですね。

    return +{
        apple => 'りんご',
        phone => '電話',
        map { $_->{english} => $_->{japanese} }
          ( $sports->@*, map { $_->{english} => $_->{japanese} } $colors->@* ),
    };

mapのところを一部展開してみると、こんな感じですね。どうみてもおかしいね。

        map { $_->{english} => $_->{japanese} } (
            (
                {
                    english  => 'soccer',
                    japanese => 'サッカー'
                },
                {
                    english  => 'baseball',
                    japanese => '野球',
                },
            ),
            ( red => '赤', yellow => '黄' )
        ),

というわけで、解決策は、「(一番最後以外の)mapに明示的に括弧をつける」でした。
ただ、次にまたmapが増えるかもしれないことを考えると、統一感を出して全てに括弧をつけるようにすることが良いと思います。
括弧の付け方は二通りあります。

( map { $_->{english} => $_->{japanese} } $sports->@* ),
map ( { $_->{english} => $_->{japanese} } $colors->@* ),

この状態でDeparseすると、括弧の位置が意図した通りになっています。

map({$_->{'english'}, $_->{'japanese'};} @$sports), map({$_->{'english'}, $_->{'japanese'};} @$colors)

おわり

というわけなので、mapとハッシュと配列を一緒に扱うときは気をつけましょう。

そして、DPPとか、Deparseとか、これらは困った時に役立ちますね。
はやくこういったツールを使いこなせるようになりたいと思います。

ところで、はてなキーワードにはPerl関係のキーワードが豊富そうで、書いていて面白いですね。
読んでいてわからなかったらキーワードに飛んでくれ!ってできそうなところが良い。

id:CNaanのこれからのご活躍にご期待ください( ˘ω˘ )
では。

Rubyでも同じようなことできるのかなって思って途中までやったけどPerlと全然違うコードになってきたのでやめた (¦3ꇤ[▓▓]

*1:特に既存のコードの雰囲気を真似て書こうとする人とか

VR空間で歩いた軌跡のログをとって見てみる

はじめに

この記事は、SLP KBIT Advent Calendar 2021の10日目の記事です。
他の部員の記事は以下からご覧ください!
adventar.org

今回は、VRでの動きのログをとって、眺めることができたら面白そうだな~~と思ったので、それをやっていきます。
GitHubのURLは → Amakuchisan/WalkingHistory です。

簡単に見ていくために、今回は下のような空間を用意しました。

f:id:CNaan:20211209230004p:plain
上から見た図

ヘッドセットはOculus Quest2を使い、プレイヤーの移動などは、Oculus Integrationのアセットを使用しています。
また、Quest2を使っていますが、今回はOculus Linkを使って、PCアプリケーションとして作成しています。

assetstore.unity.com

VR空間での移動を実装することはとても簡単で、Oculus Integrationのアセットからコピー&ペーストするだけで実装できます。

Assets/Oculus/VR/PrefabsのOVRPlayerControllerをシーンに配置し、Assets/Oculus/SampleFramework/Usage/にあるCustom Handsのシーンから、設定済みのCustomHandLeft、CustomHandRightの二つを拝借し、それぞれをOVRPlayerControllerのLeftおよびRightHandAnchorに貼り付けるだけです。

初期設定などは、よくこちらを参考にしています。

framesynthesis.jp

ファイルの生成

ファイルの生成場所

ファイルの保存場所には、実行中の一時的なデータの保存場所や、永続的なデータの保存場所など、いくつか存在します。
今回取りたいログは、永続的に保存したいので、Application-persistentDataPath - Unity スクリプトリファレンスを使います。

Application.persistentDataPathは、永続的なデータディレクトリのパスを返します。
例えば、私のWindowsの環境の場合は、 C:\Users\{ユーザ名}\AppData\LocalLow\DefaultCompany\プロジェクト名 を指します。

データの取得

今回は、2秒に1回のペースで現在の時間、位置情報と正面の向きを取得するようにしました。
(正面の向きについては、一応取得はしましたが、今回ほとんど役立っていません。今後機能が拡張されたときに使われる予定です)

public class Walking
{
    public DateTime time;
    public Vector3 position;
    public Vector3 forward;
    public Walking(DateTime time, Vector3 position, Vector3 forward)
    {
        this.time = time;
        this.position = position;
        this.forward = forward;
    }
}

Aボタンを押したときに、データをファイルに保存するようにしました。
コントローラのマッピングは、下記を参考にしています。
Map Controllers | Oculus Developers

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WalkingHistory : MonoBehaviour
{
    [SerializeField]
    private GameObject player;
    [SerializeField]
    private GameObject LogObj;
    private Log Log;
    private List<Walking> walkings = new List<Walking>();
    // Start is called before the first frame update
    void Start()
    {
        Log = LogObj.GetComponent<Log>();
        // ログの記録
        StartCoroutine(nameof(AddWalkingHistory));
    }

    void Update()
    {
        // Aボタンが押されたら、ログを保存する
        if (OVRInput.Get(OVRInput.Button.One))
        {
            // ファイル名は、適当にsampleuser_20211210080000.csvみたいな感じにしてみた
            var fileName = "sampleuser_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".csv";
            Save(fileName, walkings);
        }
    }

    private void Save(string fileName, List<Walking> history)
    {
        // 後ほど作成。ファイル名と文字列のリストを渡してファイルを保存する。
        Log.Output(fileName, WalkingListToCSV(history));
    }

AddWalkingHistory関数を使って、ログをリストに追加していきます。
2秒に1回ログを追加するために、コルーチンを使っています。

    private IEnumerator AddWalkingHistory()
    {
        WaitForSeconds cachedWait = new WaitForSeconds(2f);
        while (true)
        {
            walkings.Add(new Walking(
                DateTime.Now,
                player.transform.position,
                player.transform.Find("OVRCameraRig/TrackingSpace/CenterEyeAnchor").transform.forward
            ));
            // 2秒待つ
            yield return cachedWait;
        }
    }

WalkingのリストをCSVのリストに変換する関数はこんな感じです。
position.ToString()みたいな感じにしようかと思っていたんですが、その場合
(position.x, position.y, position.z)という形で出力されるので、カッコの扱いに悩んで却下しました。

    private List<string> WalkingListToCSV(List<Walking> history) {
        List<string> str = new List<string>() {"time,position.x,position.y,position.z,forward.x,forward.y,forward.z"};
        for (int i = 0; i < history.Count; i++)
        {
            str.Add(string.Join(",", new List<string>(){
                history[i].time.ToString(), 
                history[i].position.x.ToString(),
                history[i].position.y.ToString(),
                history[i].position.z.ToString(),
                history[i].forward.x.ToString(),
                history[i].forward.y.ToString(),
                history[i].forward.z.ToString()
            }));
        }
        return str;
    }
}

ファイルの保存

実際にファイルを保存する、Logクラスです。
はじめに、CreateDirectory関数を使って、Logディレクトリがなかった場合は作成するようにしています。

using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class Log : MonoBehaviour
{
    private static string filePath = string.Empty;

    void Start()
    {
        CreateDirectory();
    }

    // ログ保存用のディレクトリを作成
    private void CreateDirectory()
    {
        filePath = Application.persistentDataPath + "/Log/";

        if (!Directory.Exists(filePath))
        {
            Directory.CreateDirectory(filePath);
        }
    }
    
    // ログファイルの保存
    public static void Output(string fileName, List<string> logs)
    {
        var FullPath = Path.Combine(filePath, fileName);
        File.WriteAllLines(FullPath, logs);
    }
}

WalkingHisotry.csは、OVRPlayerControllerにアタッチして、Log.csはLogにアタッチしています。
Planeは床で、Cylinderは立っている三つの円柱のことです。

f:id:CNaan:20211210043026p:plain
Hierarchy

取得したログ

ログはこんな感じで取れました。

f:id:CNaan:20211210041500p:plain
取得した位置のログデータ

生成したCSVファイルのデータを利用する

先ほどのシーンを複製し、取得したデータを利用するシーンを作成しました。
LoadとPlotsという空のGameObjectを作成し、それぞれにこれから作成するLoad.cs、PlotHistory.csをアタッチします。

また、軌跡を見る際はVRを使わないので、OVRPlayerControllerは削除し、TopCameraという、真上からのカメラを用意しました。

f:id:CNaan:20211210044414p:plain
Hierarchy

CSVファイルの読み込み

Load関数を作成しました。
ファイル名を良い感じに過去のデータ一覧から選択できるようにする手間を惜しんだので、
ファイル名は固定で、data.csvのファイルのみ読み込むコードになっています。

File.ReadAllLinesでCSVファイルをすべて読み込むと、1行ずつ文字列の配列dataに格納されます。
1行を、さらに ' , ' で区切ることで、time、position、forwardを取得することができます。

Convert.ToSingleは、文字列をfloatに変換するために使用しています。

using System.Collections.Generic;
using UnityEngine;
using System;
using System.IO;

public class Load : MonoBehaviour
{
    private List<Walking> walkings;

    void Start()
    {
        walkings = new List<Walking>();
        var filePath = Application.persistentDataPath + "/Log/data.csv";
        Read(filePath);
    }
    public void Read(string filePath)
    {
        if (File.Exists(filePath))
        {
            string[] data = File.ReadAllLines(filePath);
            for (int i = 1; i < data.Length; i++)
            {
                string[] d = data[i].Split(',');
                walkings.Add(new Walking(
                    DateTime.Parse(d[0]),
                    new Vector3(Convert.ToSingle(d[1]), Convert.ToSingle(d[2]), Convert.ToSingle(d[3])),
                    new Vector3(Convert.ToSingle(d[4]), Convert.ToSingle(d[5]), Convert.ToSingle(d[6]))
                ));
            }
        }
    }
}

立っていた場所にキューブを置いてみる

キューブを生成する関数、Plotを作成しました。
Inspectorで、キューブのオブジェクトと、何秒(seconds)に1個キューブを置くかを指定します。
CubeObj.transform.parent = transform; とすることで、Plotsの子オブジェクトとして、Cubeが生成されます。

public class PlotHistory : MonoBehaviour
{
    [SerializeField]
    private GameObject Cube;
    [SerializeField]
    private float seconds = 2.0f;

    // 2秒に1回生成する
    public IEnumerator Plot(List<Walking> walkings)
    {
        WaitForSeconds cachedWait = new WaitForSeconds(seconds);
        GameObject CubeObj;
        for (int i = 0; i < walkings.Count; i++)
        {
            CubeObj = Instantiate(Cube, walkings[i].position, Quaternion.Euler(walkings[i].forward));
            CubeObj.transform.parent = transform;
            // seconds 秒待つ
            yield return cachedWait;
        }
    }

この関数を、先程のLoad関数で、ファイルを読み込んだあと(if (File.Exists(filePath))の中の最後の行)で呼び出すようにします。

StartCoroutine(Plots.GetComponent<PlotHistory>().Plot(walkings));

これで、ログに記録した場所にキューブが生成されるようになりました。

線を引いてみる

最後に、キューブを置いた場所に線を引いてみます。
こちらの記事を参考にしました。

【Unity】今更ながらLineRendererで線を引く - 原カバンは鞄のお店ではありません。


RenderLine関数を作成しました。

    [SerializeField]
    private GameObject Line;

    private void RenderLine(List<Walking> walkings)
    {
        GameObject beam = Instantiate(Line, walkings[0].position, Quaternion.Euler(walkings[0].forward));
        LineRenderer line = beam.GetComponent<LineRenderer>();
        // 頂点数
        line.positionCount = walkings.Count;
        // 線を引く
        for (int i = 0; i < walkings.Count; i++)
        {
            line.SetPosition(i, walkings[i].position);
        }
    }

このとき、Inspectorはこんな感じです。
早めの、0.2秒に1個のキューブを生成するようにしています。

f:id:CNaan:20211210042030p:plain
PlotsのInspector

この関数を、Plot関数の最後に呼び出してやると、下のGIFのようになります。

f:id:CNaan:20211210035115g:plain
移動した場所にキューブを置いて線を引いているGIF

おわりに

キューブとキューブの間隔が広かったら、急いで移動したんだなとか、
間隔が狭かったら、ゆっくりor立ち止まっていたんだなとか、
そういったことが見られて結構面白いと思いました。

本当は、複数の移動パターンを用意して、ログ同士の比較をして、「何%くらい同じような動きをした」とか出したかったんですが、今回はそこまでは行っていません。
今後、それは別件でやる予定があるので、気が向いたら続きとしてブログに記述したいですね。
(こういうと、結局何も書かずに数ヶ月たったりすることが多いですが、3ヶ月以内くらいに続きを書けたらいいなと思っています)