Quantcast
Channel: Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)
Viewing all 223 articles
Browse latest View live

インターンでゲームのデバッグツール機能開発をした話

$
0
0

こんにちは、アカツキサマーインターンシップ2019第2期生の伊藤です。 

今回、8/1〜8/23の平日16日の間*1、株式会社アカツキでサーバサイドエンジニアとして就業してきました。

この場を借りて、インターンで何をやったのか、色々アレコレどうだったのかを振り返りたいと思います。

インターン内定まで

4月のサポーターズ逆求人イベントで、アカツキの人事さんとエンジニアの方とお話する機会がありました。

丁度ゲーム系のインターンを探していたので、イベント後早速選考を進めることにし、5月中にはインターンの内定を得ることができました。選考自体はプログラミングのWebテストと1回の面接のみで、特に対策等は行っていません。

やったこと

僕はサーバサイドのコースで応募したので、面接の段階でRubyのコースとElixirのコースを選ぶタイミングがありました。元々はゲームサーバのプロダクションコードを触りたいなと思っていたのですが、以前から気になっていたElixirにここで触れるのも巡り合わせかなと思い、Elixirのコースを選択しました。*2内容はデバッグツールの開発ということで、個人的にゲーム開発を行っている自身としても良い経験を得られそうだと感じていました。

さて、実際にインターンでやったことですが、主に以下の3つの機能実装となります。

  • デバッグツールのパーティ編成機能
  • デバッグツールのパーティメンバー能力値操作機能
  • デバッグツールのパーティメンバースキル操作機能

以上を実装する背景は、テスト環境において、様々なレベルの敵とのバトルのデバッグや、特定のゲーム進行状況を再現しづらいという点を解消するというものでした。デバッグはプログラムに詳しくない方々も行うので、簡単な操作で複雑な状況を再現出来ることが必要不可欠でした。

今回担当したタイトルでは、ゲームロジックがサーバに寄った設計になっているため、デバッグツールはWebアプリケーションとして実装されています。技術的には、WebアプリケーションフレームワークとしてPhoenix、UIフレームワークとしてVue.jsが採用されています。

詰まったこと

初めて触れる言語ということもあり、Elixir周りで躓くことが多々ありました。また、Vue.jsに慣れていないためVue.jsの気持ちに沿ったデータ構造設計が行えず、最終的にリファクタリング作業をすることで無駄に工数を増やしてしまうということがありました。

以下でざっくり振り返ります。

Elixirの詰まりポイント

Elixirは動的型付けではありますが、Typespecという型表記法やDialyzerという静的型解析ツールがあり、比較的型に対する意識が高い言語です。

また、Elixirはトラディショナルなオブジェクト指向言語とは異なり、クラスという概念はありません。*3さらに継承も無いため、それぞれ独立した構造体、リストやマップなどによってデータを表現することになります。この辺はなんとなくGo言語に近い雰囲気を感じます。

なるほどプリミティブと構造体で上手いことデータ構造を表現して、Dialyzerを用いれば静的型付け言語と同じような気持ちでコードを書けそうだ、というのが最初の感想でした。

ここで問題となったのが、DialyzerはStructural Subtypingでは無さそう(=Nominal Subtyping)ということでした。Nominal Subtypingとは型名だけでデータ構造を特定するような型システムのことで、C言語やJavaなどほとんどの言語ではこちらが採用されているようです。対となるStructural Subtypingは、ある型のデータ構造を含む、より大きなデータ構造があった場合に、大きい方を小さい方の型としても扱うことが出来るような型システムです。こちらはML系言語(OCamlとか)などで採用されているようです。

Elixirのような継承を許さない構造体を扱う言語の静的型解析にはStructural Subtypingを期待してしまうのですが、どうやら違うようです。なので、基本的に複数の構造体の結合はMapとして表現するしかない、ということが起きます。そもそもMap.mergeしているのだからMapになるだろうと言われればそれまでですが。厳密にやりたいなら、その複数の構造体の結合を新たな構造体として定義する必要があります。

静的型解析が要らないというのであればこれは問題ですら無いのですが*4、新たに巨大なコードベースに立ち向かうには型という案内図があった方が都合が良いです。その案内図(型定義)が大雑把(map)過ぎて、案内図の機能を果たしていないというのが今回の問題でした。*5

素人の感想ですが、Ectoを使っているとやりがちな問題なのかな?と思いました。

なぜこのようなことになっているのか詳しく理由を聞くと、途中からDialyzerを導入したプロジェクトなので現状仕方なくそうしているということでした。問題意識はあるようですので、今後改善されて行くのだろうなと思います。

Vue.jsの詰まりポイント

Vue.jsはリアクティブにデータバインディングしてくれるイマドキなフロントエンドフレームワークなのですが、どこまでならリアクティブにバインディングしてくれるのかを理解していないと辛いことになります(なりました)。特に変なデータ更新の仕方をするとハマりがちなことかと思います。

Vue.jsはdataで定義したフィールドは追跡してくれるのですが、新たに追加されたフィールドは基本的に面倒を見てくれません。内部的にはクラスのsetterを利用しているため、テンプレートの方で指定しているフィールドがなければそのsetterは生成されないようです。*6

結論として、データはsetterが引かれるような更新の仕方をするべきでした。

感想

田舎の大学に通っているため、3週間とはいえ東京で生きていけるか不安でしたが、意外となんとかなりました。まあ、人ごみと暑さにはなかなか慣れませんが…

遠方(岩手)からの参加だったのでマンスリーマンションを用意して頂きましたが、結構快適でした。洗濯機・冷蔵庫・電子レンジ・IH以外ほとんど何も無いので、ある程度日用品調達は必要でしたが、真下にコンビニがあったので特に困ることも無く。アカツキのある目黒まで電車で2駅なので、通勤もそれほど苦ではありませんでした。

業務内容はメンターさんが事前にきっちりタスク化してくださっていたので、ほとんどやるだけと言う感じでした。個人的には、もう少し他の社員さんとも絡みたかったなという感想です。

全体を通して満足感のあるインターンだったと感じています。

*1:途中、学会で抜けたので実際は14日間

*2:実はRubyもまともに触ったことは無いが、関数型言語の側面が強いElixirの方が興味が強かった

*3:その代わりプロトコルという機能でオブジェクト指向の特徴の1つであるポリモルフィズムを実現している

*4:実装上は問題無い

*5:一言で言うとGoの埋め込み型が欲しい

*6:動的に生やす方法はある


アカツキ就業型インターン8月の部

$
0
0

はじめまして、この度アカツキのサマーインターンに参加させていただいたので、その参加報告をいたします。

経緯

魔法のスプレッドシートで知りました。 ゲーム制作に昔から憧れがあり、やっている内容も面白そうだったので、応募しました。 なにか未経験のものがやりたいと思い、UnityとGo(サーバ)で応募しましたが、 面接で話し合った結果railsのプロジェクトに参加することになりました。 当初は希望していたところと違っていて残念だなと思っていましたが、 経験のあるrailsで参加できて、サービスの理解や設計により注力できたかなと思います。

インターンの応募

僕の場合は コーディングテスト→人事面接→技術面接という流れで進みました。

コーディングテストはプログラムを書いたことがある人ならまず通るような内容でした。 業務内容に比べるとかなり簡単だと思います。

面接は2回あり、その後に合否の連絡とメンターの方と何をやるか決める面談をしました。 技術面接でインフラ(クラウドですが)とかを触った経験があったので、そのあたりの話をしたような気がします。 似たようなことをやっていたのがよかったのでしょうか。

会社の雰囲気

支給されるPCは最新のMacBook Proでした。画面のサイズとキーボードの配列を選択できます。 僕は英字配列しか打てない人なので、キー配列を選べたのはうれしかったです。

あと、オフィスがきれいでした。オフィスの中にはセルフコンビニもあります。 ゲーム会社なのでゲーム好きな人が多いかなと思っていましたが、人それぞれであまりゲームをしない人も多かったです。 ただ僕はサーバサイドで採用されましたが、 フロントの方がゲーム好きの方が多いような気がします。

インターンの内容

業務以外

インターンの方は、数回分ランチの枠が用意されていて、 自分が参加しているチーム、職種以外の方と食事ができる機会があります。 ぼくはフロントエンドのエンジニアの方や、 研究開発のエンジニアの方とランチに行かせてもらいました。 そこでアカツキの働き方や、それぞれの分野の詳しい話を聞けたのでとてもいい経験になりました。

業務

10時~19時の3週間(16日間)の勤務でした。

実際の業務と同じような内容でした(と僕は感じました)。 時給の高さを考えるとこれぐらいは期待されているのかもしれません。 最初の3日間ぐらいは少し大変ですが、慣れてくると楽しいです。 自分の変更がマージされるときは嬉しいものです。

ソーシャルゲームのサーバサイドということもあり、 大規模なトラフィックが想定されています。

このような大規模なサービスに学生のうちから触れられるのはとてもいい経験だと思いました。

今回のインターンでは2、3個の新規のAPIを実装しました。 それに伴ってデータベースの新規設計も行いました。

仕様、設計が確定しているものについては実装は楽でしたが、 開発が始まってから数年経ち、機能やテーブルも多岐にわたるので、 個々の機能を理解するまでが大変でした。 なので新規機能の開発を始めるときは、既存機能の仕様、コードを確認し、 そこから新規機能の仕様を確認し、実装を始めるという感じでした。

感想

短期のインターンでは実際の業務とは違うことをやらされたりして、満足行かないことは多いと思いますが、 今回のインターンはリリースされているゲームのバックエンドの実装ということで、とてもいい経験になりました。

アカツキでサーバーサイドエンジニアを経験してみて

$
0
0

こんにちは!今回はサマーインターンで大規模プロジェクトにサーバーサイドとして携わらせていただきましたDopponです!8月1日〜8月23日までみっちりインターンさせていただいて、本当に多くのことを学び、たくさんの気づきがあり、アカツキの雰囲気にどっぷり浸かってきたのでアウトプットしようと思います!

1. チームの雰囲気 

これまで実は他でもインターンを長期で経験してました。なのである程度は働き方のイメージは出来ていたのですが、やっぱり誰にでも不安はある訳で、チームの仲間とちゃんとコミュニケーションを取れたりメンターさんと上手くやれるかなどは気にしていました。

しかし、実際のところそんな心配は杞憂に終わりました笑

まずメンターさんが色々配慮してくださり、自分が全力でやれる環境づくりを整えてくださったのが一番大きかったです。本当にありがとうございました。次にチームの雰囲気も良かったことが印象的です。ゲーム会社だからって事もあるからか(?)、好きなゲームの話だったり好きなアニメについて話せばだいたい通じるので、馴染むのにそう時間はかからなかったです笑

自分のサーバーサイドのメンバーはだいたい8人ぐらいで、それぞれが強みを持っている印象で良くこんな人たちを集めてチームを作りあげたなって正直に思いました。

なので本当に雰囲気も良くて、そうそう会って話を聞く事すら出来ないエンジニアのもとで一緒に働けた事が誇りに思います。

じゃあ、自分の大好きなメンターさんと一緒にランチに連れてってもらってインターン中一番美味しかったカレーの写真を載せて、どんな開発をしたのか話して行こうと思います!

f:id:dopponpon:20190823092052j:plain

 

2. 開発 

実際のインターン内容ですが、ざっくりあるゲームタイトルの友達機能の周りの改修を手伝いました。
メンターさんとお話をする中で、今後に向けて友達機能周りを充実させていくのが良さそう、と思ったのがきっかけです。

実は初日のミーティングで、色々残っているタスクなどを共有していただいてその中でこの友達機能の改善に興味が湧いたのも大きいです。実は自分と同じタイミングで同じチームに配属されたインターン生は別タスクでログの解析を行ったりと、結構やることはバラバラで自分で手をあげて選ぶって感じがありました!

こういった雰囲気はアカツキの会社説明のときに良く聞いていたので、そのまんまだなと思いました。

 

3. 今後について 

この短期間で本当に色々な経験をさせていただいて、学ぶこと、反省することが多かったです。友達機能の設計は考え所が多く、自分でも以前につまづいていた箇所だったので、今回実際にどういう設計で作られているのかを知れたのは、それだけでも大きな収穫でした。
もちろんそれだけでなく、アカツキの文化に触れられたこと、どういう風に開発に向き合い、どういうこだわりを持って進めているのかを体感できたこともとても良かったです。
また、今回のインターンで全国から集まった学生と交流することで自分の至らない点を痛感することもあり、もっと努力しなければと励みにもなりました。どこかでこのお礼ができるようになるためにも、沢山勉強してもっと力をつけようと思います!

リーン・スタートアップはもう古い?企業内新規事業でよみがえるLeanな事業立ち上げ <#00.はじめに>

$
0
0

こんにちは、ライブエクスペリエンス事業部のポリック (id: poric_ries) こと、赤堀です。エンジニアではなくビズの人間です。

 

さて、突然ですが、僕らのチームでは今こんなサービスを作っています。

joymo.herokuapp.com

 

このサービスはまだベータ版の域を出ませんが、アイディアや検証の仕方について次のような反響をいただいています。

 

この反響をみて、僕らのチームの取り組みを共有することが、世の中に役に立つのかもしれないと思い立ち、ブログで公開していくことにしました。

f:id:poric_ries:20180316125433p:plain

今回は#0ということで、本ブログの目的や背景、今後配信予定のコンテンツなどについてお話します。

 

1.目的

①世の中に新たな価値を生み出そうとするチーム/人の生産性を上げることに貢献したい
アカツキの新規事業に興味・関心を持ってくださる方を増やしたい

 

2.背景

「なぜ、ブログを書こうと思ったか?」

僕らのチームの経験をありのまま共有することが、世の中の役に立つと思ったからです。さらに掘り下げると、次のような背景があります。

 

実践に役立つ新規事業開発ノウハウが共有されていない

スタートアップはもちろん、あらゆる企業で新規事業への取り組みが盛んな昨今にも関わらず、私のようなビズ/ディレクターにとって実践に役立つノウハウは世の中にまだわずかしか共有されていません。

エンジニアにとっては当たり前である、リアルタイムかつリアル(ソースコード含む)な「知の共有」という習慣が、(少なくとも日本の)ビズ/ディレクターにはあまり根付いていません。

 

なぜ、新規事業開発ノウハウは共有されにくいのか?

この要因は、誤解を恐れずにいえば、ビズ/ディレクターにとっては、情報の非対称性を生むことこそが競争優位性であり、自らの経験によって得た貴重な情報を共有するという習慣が根付きにくいためです。

たしかに「あるKPIが分かれば、その事業の成否が透けてしまう」ことは起こりえるため、情報開示に敏感になるのは当然ですが、それに引きづられて、事業成否を映すKPIとは本来”切り離せる”はずのノウハウまで共有されにくく、共有されたとしても抽象化され実践には活かしにくい形になってしまっています。

 

ビズ/ディレクター間でも”知の循環”を促したい 

エンジニアのコミュニティに存在する、実践に即した知の循環(恩恵を受けたら還元する)が回っていない。そんな状況を少しでも改善すべく、本ブログは次のようなコンセプトを設定しています。

 

3.コンセプト

リアル”なノウハウを”リアルタイム”に届ける

リアル

抽象化したノウハウは実践には役に立ちません。実践に役立つのは、抽象化したノウハウにもとづくリアルな実践録です。

一方、抽象化したノウハウは、起きた事象を体系立てて整理したり、そこから真因を抽出し打ち手を講じる際に参考にすることで、振り返り(学び)の精度を上げることには大いに役立ちます。

リアルタイム

日々変化の激しい業界であり、できるかぎり即時性を持ってノウハウを届けることに価値があります。

 

4.ターゲット

新規事業の立ち上げに携わる全てのチーム/人

コアターゲットは企業内新規事業担当者(ビズ/ディレクター)=私と同じ立場の方

  

5.今後配信予定のコンテンツ

冒頭で紹介した「JOYMO」にたどり着くまでの2ヶ月に渡るチャレンジと悪戦苦闘の日々を、会社に怒られるラインのギリギリを狙って、できるかぎり透明性を持って情報公開していきます!

配信予定のコンテンツは以下の通りです。

コンテンツ一覧

#1.今回の新規事業開発における前提・制約条件
アカツキ社、ライブエクスペリエンス事業とは
・今回の前提、制約条件
・新規事業開発プロセスの方針

#2.走り出す前の準備
・メンバー
・必読本
・ファシリティ
・テーマ設定 など

#3.とりあえず走ってみる!Running Lean&SPRINTの実践
・やったこと(時系列でなるべく詳細に)
・結果と振り返り

#4.方法論をチューニングしながら走る!

#5.走り慣れてきた!案外すぐにゴールできるんじゃね!とおもったら...

#6.ターゲットを変えて再スタート!

#7.ダーティー&セクシーな検証

#8.to be determined

 

7.[改めての紹介] 2ヶ月間のランでたどり着いた新サービス「JOYMO(ジョイモ)」

今年1月から走り出した新規事業開発の過程で見つけた新サービスの原石です。まだまだ正式ローンチにはほど遠いものの、ありそうでなかった新しい価値を提供しうるサービスです。

joymo.herokuapp.com

  

以上、いかがでしたか?

 本編#1以降を読みたいと思った方は、ぜひB!&シェアをお願いします!「こんな内容も知りたい」などあればコメントをお待ちしています。


「ユーザーが欲しがるものを作れ!」by Paul Graham@Y Combinator

Akatsuki GAME JAM 2019開催レポート

$
0
0

こんにちは、エンジニア採用担当の宮田です。

去る9月14日(土)〜15日(日)に2021年新卒エンジニア向けインターンシップ「Akatsuki GAME JAM」を開催いたしました。いきなりの報告ですが、今年は全チームがゲームをつくりきり、優勝争いも稀に見る接戦で最高の盛り上がりを見せました!

今年で第5回となるその様子を紹介いたします。

「Akatsuki GAME JAM」とは・・・

2日間を通して、ゲームアイデアの企画から開発までを行い、その制作物の結果を競い合うという内容です。今回のテーマはアカツキを象徴する言葉でもある「カラフル」。参加者は22人が7チームに分かれ、Unityで開発を行います。

 ▼詳細はこちら

https://aktsk.jp/recruit/gamejam2019/

#なぜこのようなインターンシップをアカツキが実施するのか

私たちは本気で「ゲーム開発を通して、世の中をワクワクさせるようなプロダクトづくりを体験をしてもらいたい!」という想いを持っているからです。

もちろん、参加者の中にはこれまでにもハッカソン参加の経験や、サークルなどでのチーム開発を経験している参加者も多くいました。一方で、多くの学生とお話をする中で、プロのエンジニアから近い距離でアドバイスをもらいながら、開発を進める経験をしたことがある学生は少なく、その機会を創出したいと思い開催しています。今年で5回目になりますが、毎年パワーアップしております。

#昨年から何を変えたのか

メンターの関わり方を昨年より濃密にしました。従来も各チームにメンターがつき、開発をサポートしていましたが、今回は「コーディング以外は全て関わる」ことに決めました。どこまで関わるかは毎回議論に上がりますし、過度なサポートは参加者の気づきや成長を制限してしまうことから、従来はヘルプの声が上がった際のサポートにとどめていました。

しかし、2日間という限られた時間では、完成まで至るチームが半数程度だったことや、面白さへのこだわりをやり遂げきれないチームも見てきました。その中でタスク・スケジュール管理の重要性などは実感できたとは思いますが、どうしても悔しさが勝ってしまいます。やはりゲームを完成させ、それを他の参加者に触ってもらったり、フィードバックをもらうことによって得られる体験の方が重要だと判断し、今回の変更を行いました。

結果として、全チームがゲームを作りきり、各チームの個性を磨くところまで進めることができました!(もちろんこの結果は、メンターのサポートだけの成果ではなく、参加者みなさんの本気がもたらしたものですが!)

#当日の様子

今回の参加者は2月から実施しているAkatsuki Geek Live(LT会)や面談・面接を行った学生、中には学部の時に初めてお会いした学生が院生となって参加してくれたケースもありました。当日までにもすでに感慨深い時間を過ごしていましたが、準備期間はあっという間に過ぎ、開催当日を迎えました。

全参加者が時間通りに集まり(優秀!)、早速開発ゲームのアイデアソンへと移ります。

今回のアイデアソンでは「カラフル」というテーマから連想するワードを時間制限を設けて、列挙していきました。それを数十秒という短い時間で他のメンバーに回していき、新たなアイデアが足されていきます。自分だけでは思いもつかなかった発想が生まれ、その後に行ったチームの枠を超えたフィードバックによって企画が順調に磨かれていきました。

▼各アイデアへの投票の様子

f:id:kenji-hanada:20190917202032j:plain

ゲーム企画が決まったところで、いよいよ開発開始です。ここでは、2日間の開発スケジュールや役割分担決めます。今回驚かされたのは「今日の何時までに○○まで開発が進んでいなければ、軌道修正をする!」という意思決定のマイルストーンをしっかり置けているチームが複数いたことです。短期型ハッカソンの開発スタート時点でここまで議論できている例は珍しく、頼もしく感じました。

▼タスクを整理し、いよいよ開発開始!

f:id:kenji-hanada:20190917202026j:plain

f:id:kenji-hanada:20190917202020j:plain

開発時間は白熱する議論や笑い声が飛び交い、非常に熱量の高い空間であったことが印象的でした。毎年のことではありますが、開発時間はあっという間に過ぎ、2日目いよいよ成果物のプレゼンテーションへと移ります。

各チーム、プレゼンの練習も入念に行い、自分たちのゲームのこだわりや面白みを魅力的に伝えてくれました!また、多くのチームがOne more thing..として、サプライズを準備してくれた点も非常に盛り上がりました!(PC版だけではなくiOSやVRでの実装、ロジックが複雑なゲームの対戦機能の追加など)

▼プレゼンテーションの様子

f:id:kenji-hanada:20190917202017j:plain

発表に対しては、メンターを務めたアカツキメンバーや、参加者からも多くの質問が飛び交いました。

▼アカツキメンバーからの質問タイム

f:id:kenji-hanada:20190917202012j:plain発表後には、各チームのゲームを体験できる試遊会を実施。音声認識のゲームを開発したチームブースからは大声が聞こえるなど、盛り上がりを見せていました(非常に尖ったゲームアイデアだったのですが、内容がアレなので詳細は控えます笑)。

そして、いよいよ運命の結果発表へ。

まずは準優勝チーム。「るつぼロケット」というタイトルを開発してくれたチーム「ナンカレー」が見事に受賞!完成度の高いビジュアルに止まらず、細部のエフェクトや操作性・ゲーム性を備えた作品に審査員の評価が集まりました!準優勝賞品はアカツキオリジナルカレーだったので、結果的にピッタリの受賞でした笑

▼準優勝チーム

f:id:kenji-hanada:20190917202009j:plain

 

そして、栄えある優勝は「Skeleton Board」というカジュアルゲームを開発したチーム「お願いマッスル」でした!敢えて、超シンプルなゲームでの勝負でしたが、テーマを活かしつつ、シームレスな画面遷移やレベルデザインの調整など、リリースレベルまで仕上げてきたことが高く評価され、見事優勝をおさめました!

▼優勝チーム

f:id:kenji-hanada:20190917202005j:plain

#最後に、運営側としての感想

一言で表すと「みんな最高でした!!」。何より、様々なアイデアを形にしていく時のみなさんのキラキラした笑顔。時に悩みながらも課題を解決した際の興奮。見ているこちら側が羨ましくなるくらいの時間をプレゼントしてもらいました。

実は今回、運営側として最初に決めていたことがありました。この夏の出会いを、この場限りで終わらせるのではなく、紡いでいきたいという想いから参加者の同窓会を企画しています。その同窓会を待ち望むには十分過ぎるほどの熱い時間を過ごさせてもらえました!数ヶ月後の再会を約束し、晴れやかにまた新たな挑戦に歩みを進めたみなさんを心から応援しています!

▼懇親会で達成感を分かち合うみなさん

f:id:kenji-hanada:20190917202001j:plain

▼参加者のみなさん、本当にお疲れ様でした!

f:id:kenji-hanada:20190917201953j:plain



良いチーム構成のための図解思考法

$
0
0

こんにちは、ゆのん(id:yunon_phys)です。みなさん、良いチームを作っていますか? 良いプロダクトを生み出すためには、良いチームであることが必要です。 そして、良いチームであるためには、チーム構成が適切になっていないといけません。 今回は、どうやったら良いチーム構成が出来るか、というのを考えるときの思考ツールをご紹介します。

チーム構成はトレードオフの関係がある

モバイルゲームはリリースがゴールではなく、むしろリリースがスタートです。 リリースしたら何年も運用することになり、プロダクトチームは数ヶ月~1年後を見据えてアセットを用意していきます。 そのため、いかに普段から効率的にアウトプットを出せるプロダクト組織を作ることが重要です。*1効率的にアウトプットを出すためには様々な工夫をしますが、プロダクト組織内のチーム構成も一つの鍵です。

私がチーム構成が鍵であると考えるようになったのは、私が関わっているプロダクト組織で、今後のチーム構成をデザインする場面に立ち会ったときです。 あれこれ思考錯誤を重ねても、どうしてもベストなやり方が思いつかず・・・。 それもそのはず、チーム構成は、何かを重視すると、何かが失われるトレードオフの関係にあるのです。 例えば、エンジニアがいるチームとエンジニアがいないチームを構成すると、エンジニアのいないメンバーだけで行動するときは機動力が高くなるが、逆にエンジニアがいないチームでエンジニアのスキルが必要になったときにそのための調整コストが発生します。

これまで様々なプロダクト組織を渡り歩いてきましたが、一つとして同じチーム構成は見ていません。 これは、プロダクトが開発・運用されていく過程での複雑な歴史的経緯を経ながらチームが形成されてきた結果のように見えます。 例えば、開発・運用拠点の違い、在籍メンバーのスキルセット、週に何回デプロイするのか、チームやメンバーの学習によるスキル向上、拠点ごとの文化の相違、メンバーの出入りなどが関係しています。

さらに、チームはまるで生き物のように変化していきます。 状況が変わると、トレードオフのポイントが変わっていき、これまでなんともなかったものが気になりだしたり、逆にこれまで一生懸命やっていたものがそこまで問題でなくなったりします。

このように、チーム構成を固定化して考えるのはあまり意味がなく、そこに至った背景や今後どうなるのかを見据えることが重要です。

トレードオフの可視化

チーム構成にはトレードオフがあることは既に話しましたが、このトレードオフをいざ言葉で出してみようとしました。 しかし、言葉で書き示しても、なんとなく実感値がなかったり、トレードオフの抜け漏れが発生するような感覚がありました。 そこで、より直感的に考慮すべき点が抜けもれなく可視化出来るようにしたのが、今回提案する良いチーム構成のための図解思考法という思考ツールです。

今回はアカツキにおけるモバイルゲームの運用プロダクトのチーム構成を題材にします。 以下に示す図はゲーム開発において、トレードオフの観点を洗い出したものです。 f:id:yunon_phys:20190930163912p:plain横に並べているBOXがMECEに洗い出したプロダクト組織でやることで、隙間はその事象が独立していることを表します。 事象が独立しているということは、ここでチームを分割するとコミュニケーションコストが最小化されます。 縦に並んでいるのは同時に考えなければいけない観点です。

例えば、ゲーム内のイベントを開催するケースを考えましょう。 イベントを開催するためには、アセットデータやマスターデータの更新が必要となります。これを図では”コンテンツ更新”という言葉にしています。 次に、開催しようとしているイベントが従来のスキームで開催出来るのか、開催出来ないのかが観点となります。 もしスキームの変更が必要になるなら機能追加・改修が必要となります。 さらに、機能追加・改修にはソースコードの改修を伴いますが、モバイルアプリの更新(バイナリ改修)とサーバーの更新が必要となる場合もあるし、サーバーの更新のみとなる場合もあります。

次に、この図に垂直な縦線を入れてみましょう。 f:id:yunon_phys:20190930165518p:plain縦線を入れると観点のBOXが分断されることがわかります。この分断こそがチームを構成する行為となります。 図で明らかなように、どこに縦線を入れても、切れ目に沿った綺麗な切り方は無く、必ずBOXが分断されます。 BOXが分断されると、本来はコミュニケーションをとらなければいけない人たちが違うチームになるので、コミュニケーションの隔たりが発生しやすくなります。 このコミュニケーションの隔たりを解消するために、多くの組織では、 定例会議を作ったり、そのコミュニケーションハブを作ったり、座席を工夫したり、という行動をおそらく取るのでしょう。 他にも、クライアントもサーバーも出来るエンジニアを採用する、モバイルアプリのアップデートは半年に1回だから多少のコミュニケーションの隔たりは許容する、という手段を取る場合もあります。

このように、分断が可視化されたら、ではそのコミュニケーションの隔たりをどう解消するか、というアクションを考えやすくなるのがこのツールの特徴です。

具体例から考えるメリット・デメリット

それでは縦線をどこに入れるかによってどのようにチームが変わっていくかを考えながら、メリット・デメリットを見てみましょう。

A. 運用チームと非運用チーム

f:id:yunon_phys:20190930164026p:plainこのチーム構成は定期的な更新(コンテンツ更新やスキーム変更に伴う新イベント追加など)は運用チームで実施し、不定期の更新(コンテンツ更新を伴わない機能改善やパフォーマンス・チューニングやインフラ改修など)は非運用チームで実施します。 運用チームはイベントの企画・実行をするプランナー、アートデザイナー、UIデザイナー、クライアントエンジニア、サーバーエンジニアが在籍するチームとなります。 非運用チームはUIデザイナー、クライアントエンジニア、サーバーエンジニア、インフラエンジニア*2が在籍するチームとなります。

この構成のメリットとしては、定期的な更新は1つのチーム内で完結するため、定期的な更新が多いチームにとってはコミュニケーションが簡単化するということです。

一方、デメリットとしては、”機能追加・改修”と"バイナリ改修必要”のBoxが分断されているように、それぞれの行動を取ろうと思ったときに情報の同期が必要となることです。 例えば、運用チームで新イベントのスキームを追加するためにUIやクライアントコードを改修しようとしているときに、非運用チームでよりプレイヤーが快適に操作できるようにUIの改修やパフォーマンス・チューニングなどをしようとしてコンフリクトが発生する可能性があります。 逆にいうと、新イベントのスキームを稀にしか追加しないようなプロダクトであれば、このコミュニケーションコストはそこまでかからないです*3

一方、新イベントのスキームをガンガン追加したい、運用に直接関わらない機能や非機能の改善も行いたい、という場合もあります。 この場合は、お互いのチームの情報共有の定例会議を設けるとか、両方のチームに跨っている職種で情報共有会を設置するとか、QAが両方の機能チェックすることでコミュニケーションの橋渡しをする*4とか、コミュニケーションマネージャーを置く*5という方法がとられます。

B. 開発チームと運用チーム

f:id:yunon_phys:20190930164126p:plainアカツキの中でも最も良くあるチーム構成がこの構成です。 このチーム構成はソースコードに手を加えるような変更は開発チームで行い、ソースコードに手を加えずにコンテンツだけを更新するのは運用チームで行います。 開発チームは新規イベントの企画をするプランナー、UIデザイナー、クライアントエンジニア、サーバーエンジニア、インフラエンジニア等が在籍するチームとなります。 運用チームには、イベントの企画・実行をするプランナー、アートデザイナー等が在籍します。

この構成のメリットとしては、エンジニアの効率が最も最大化する構成であるため、機能をガンガン作っていくときに有効です。 例えば、リリース直後のアプリで、機能を追加したり改善することがそのまま価値となる場合に良いです。

一方、デメリットとしては、運用チームが知らない機能や想定していない機能が追加される可能性があります。 「新イベントをこう使おうと思ったが、○○出来る機能が無いからうまくイベントを設計できない」といった事態はアカツキ内でも頻繁に発生します。 これは開発チームにいるプランナーが運用チームのプランナーとどんなに情報共有していても発生してしまうので、なかなか難しい問題です。

こういった事態を防ぐには、開発を担当しているエンジニアが運用チームのプランナーに情報共有する機会を設けるとか、 本番相当のイベントスキームを開発中に運用チームのプランナーが設計するとか、 そういった事態が発生するのを見越して実際にリリースするより前のバージョンから試しておくとか*6、という方法があります。

C. 機能開発チームと非機能開発チーム

f:id:yunon_phys:20190930164844p:plain Bの開発チームをさらに、ゲームの機能開発をするチームと、ゲームには直接関係のない非機能を開発するチームにわけた構成です。 非機能開発とは、例えば、運用ツール開発、デバッグツール開発、リファクタリング、開発環境整備などです。

非機能を開発するチームにはエンジニアしか在籍しないため、非機能開発のスピードが担保されることが、この構成の特徴です。 また、いつも機能開発が優先されがちなプロダクト組織では、このように非機能を遂行するチームだけ切り分けるのも有効です。

ただし、デメリットとしては、バイナリ改修を伴う非機能開発をする場合は、機能開発チームと良くコミュニケーションをとっていなければなりません*7。 例えば、非機能開発チームで技術的負債を解消するためにリファクタリングをしていた場合、機能開発チームの想定していなかったデグレが発生する可能性があります。

こういった事態を防ぐには、機能開発チームと非機能開発チームの情報共有の場を設けるとか、非機能開発チームがやろうとしていることを機能開発チームのQAが実施する、という方法があります。

尚、機能を開発するチームにはエンジニアの数が非機能開発チームと分割されることで少なくなるため、機能開発をガンガン進めたいタイミングでは不適切です。 特に、機能開発を進めたいのに、過度に非機能開発が進められる可能性もあり、結果として機能開発用のエンジニアを採用することになりコスト高になる可能性があるので注意が必要です。

チームのベストは変化する

これまで見てきたように、常にベストなチーム構成などは存在しません。 何が最良なのかは、冷静にチームを見て、トレードオフを考慮して意思決定していくしかありません。 前のチームではこうだったからこの構成がベストである、という話がもし組織の中で行われているのだとしたら、それは危険なサインなのかもしれません。

この思考ツールを試すワークショップを体験する場として、RSGT2020にプロポーザルを出しています。 もし気になった方は、サインインしてハートマークをぽちっとしていただけるとうれしいです!

confengine.com

*1:クオリティを上げる方向に時間を使えるようにもなるので、効率化はゲーム開発においてとても重要である

*2:アカツキではサーバーエンジニアとインフラエンジニアは同一の人物が担当する

*3:しかし、新イベントのスキーム変更をほとんどしない運用チームにエンジニアを在籍させておくこと自体のコストが高いという話もある

*4:これは問題を後回しにしている可能性がある

*5:これは無駄なコストなので出来る限り避けたい

*6:しかし、どういうイベントが今のプレイヤーに喜んでいただけるのかは水物なので、これだと対応が遅れてしまうという可能性もある

*7:尚、ここでバイナリ改修が項目に上がっているのは、バイナリ更新にはプラットフォームの審査が必要となり、更新を慎重にせざるを得ないため

クライアントエンジニアインターンでUX/UI改善した話

$
0
0


こんにちは、ritoという者です。

この度、クライアントエンジニアとして3週間の就業型インターンシップに参加し、八月のシンデレラナインのUX/UI改善のために尽力させていただきました。

このインターンシップでは、僕が希望したこともあり、既に挙がっている要件を実装するのではなく改善策を提案するところから経験することができました。
本稿では僕が取り組んだ課題を決めるまでの流れと、改善案の実装について書ける範囲で書いていこうと思います。

課題決めと改善案

自分で遊んでみないことには改善案なんか出せません。

僕はハチナイをプレイしたことがなかったので
インターンが始まるまでの2週間強、暇さえあればアプリを起動して遊んでいました。

初心者ながら遊んでいてこのゲームの中で一番面白いと感じたのがデレストでした。

デレスト(シンデレラストーリー)とは
ハチナイにおいてキャラにスキルを覚えさせるためのカードゲームで、
ゲーム内で取得したポイントが所定の値に達したらスキルを習得させられるといったものです。
種々のストーリーにはそれぞれ特殊なギミックがあり、
個人的には各ギミックに応じてデッキの編成や手札を切る戦略を考えるのが、このゲームの醍醐味だと思いました。

しかし、遊んでいて思ったのが

スキルを覚えさせたいキャラが決まっているときに、
そのキャラに合わせてデッキ(オーダー)を編成しデレストを始めるまでの画面遷移が煩雑だ

ということです。

画面遷移が煩雑なあまり、
戦略を練って編成して挑戦し、また戦略を練って編成し…というサイクルを回すのが億劫になってしまいました。

そこで今回のインターンシップではこの画面遷移を簡潔化するという課題を設定しました。

細かい説明は省かせていただいて、図でお見せすると

f:id:ritoaktsk:20190828215903p:plain

デレスト開始までの画面遷移(改修前)

↑これを…

f:id:ritoaktsk:20190828220039p:plain

デレスト開始までの画面遷移(改修後)

↑こうしよう…という提案をしました。

オーダー確認画面を撤廃し画面遷移を簡潔化するのに伴って、オーダー編成画面のデザインや機能にも変更を加えました。
この変更により、ゲーム開始までの必要タップ数、画面遷移数が減少し、オーダーの一覧性が向上します。

f:id:ritoaktsk:20190828220256p:plain

オーダー編成画面の変更点

実装

話し合いを重ねて改善案の仕様を決めるのと並行して、2日ほどかけてじっくりソースコードを読み込みました。

「画面遷移を簡潔化する」

言うは易し、とはまさにこのことだと痛感しました。

画面間のデータの渡し方ひとつ取っても、
同一シーンでのビューの切り替えなのか、はたまたシーンを新たに読み込んでいるのかで
処理は大きく異なりましたし、

デレストへはホーム画面からだけでなくチーム強化からも来られるので、1つの変更が全く別の画面にまで波及することも十分にあり得ました。

とはいえ技術的にものすごくチャレンジングなことをしているわけではなかったので、
最終的にはなんとか形にはできました。
技術的な挑戦があったというよりかは、可読性・保守性に配慮したコーディングができているかといったエンジニアとしての基礎力を磨くことができたと思います。
エンジニアとしてまだペーペーの僕には非常にありがたい経験でした。

インターンを終えた感想

今回のインターンシップでは
ひとりのユーザとしての感想や仕様書、ToDoリストの内容を踏まえて自分で改善案を考え、プレゼンし、仕様を詰め設計して実装するという一連の流れを経験することができ、
大変有意義な時間を過ごせたと思います。

特に改善案のプレゼンでは好評をいただき、自分で取り組むことになった案以外にも多数、今後やるべきタスクとして採用していただけました。
大学での研究で培った経験が活きたのを実感することができ嬉しかったのを覚えています。

ただやはり上手くいかなかったことも多くあり、
中でも一番の反省点は全体的にあまりにも消極的あるいは受け身だったことです。

周りの社員さん方が忙しそうなのであれば、積極的に自分なりにできることを探したり、インターン中にはあまり関わることがない別部署の見学をさせていただいたり、色々とやりようはあったと思います。

そんな引っ込み思案な僕に対しても皆さん快く接してくださりましたし、
開発チームのメンバーで行ったお食事や1on1ミーティングなどでは、
趣味や雑談、ゲーム業界や技術の話はもちろんのこと、
僕の就職や将来についての悩み・相談事にも親身に付き合ってくださりました。
おかげで朧げだったやりたいこと・これからすべきことがはっきりしてきました。

初めてのインターンで、始まる前は不安もありましたが、ここに来れてよかったです。
お世話になったメンターさんをはじめ、サポートしていただいた開発チームの皆さんや、人事・総務の皆さんに、この場をお借りして改めてお礼申し上げます。

3週間ありがとうございました。

3週間就業型インターンでハチナイのクライアントエンジニアを経験した話

$
0
0

はじめまして!9/2〜9/20の3週間、「Akatsuki Summer Internship 2019」に参加したy-shikaです。 八月のシンデレラナインのプロジェクトでクライアントエンジニアとしてガッツリ働かせていただきながら、アカツキの雰囲気や働き方を感じてきました。 本記事ではどんな経緯でインターンが決まったのかや実際に3週間でやったことなどを書いていきたいと思います!

目次

インターン前

インターン選考

自分がアカツキと初めて繋がりを持ったのは4月中旬にあったジースタイラスさんの「逆求人プレミア@大阪」でした。 自分のブースが割り当てられ、そこに来てくれた企業の方に対してプレゼンをしたり、FBをもらったりといったイベントで1日に沢山の会社と繋がりを持てるのが魅力なのですが、丁度その時アカツキの人事の方とエンジニアの方が僕のブースに来てくれました。 もちろん前から社名は知っていたのですが、具体的な事業までは知らなくて、イベントを通して色々と社風や事業について教えてもらいました。

そしてイベント後に面接を受けさせてもらうことになり、面接終了後に技術テストを受けて無事合格との連絡を頂けました。

事前面談

事前面談はインターン開始の1ヶ月前にリモートで行いました。(自分は地方の大学院に通っているため)

面談では実際にインターンで1ヶ月お世話になるメンターさんと1on1で「どんなことがやりたい?」とか「ハチナイ遊んだことある?」なんかをラフな雰囲気で話す感じでした。 自分はハチナイをそれまで遊んだことがなかったので、インターン開始までにプレイしてやりたいことを見つけてくるといった感じになりました。

インターン中

課題決め

さてインターンが始まりました。 実際に遊んでみての感想や「ここはもっとよく出来そうだな」ってポイントをまとめていたので、それをメンターさんとプランナーさんに見て頂き、実現可能性やチーム内に溜まっているユーザさんからの要望などと照らし合わせてみて、3週間での課題を決めました。

結果として課題は「スカウトUIの改修」に決定しました。 スカウトとは他ゲームでいうガチャですね。簡単に説明するとチケットや石などでスカウトを回せるのですが、2年間運営していく中で種類も多くなり現状のUIでは快適とは言い難くなってきたようです。 ということでUIを見直して改善するといった感じです。 詳しくは後述の「インターン成果発表プレゼン」をご覧ください。

1日の流れ

ある日のスケジュール
10:00 出社
10:00 - 10:05 チーム全体朝会
10:05 - 11:00 作業
11:00 - 11:30 メンターさんと1on1面談
11:30 - 12:30 作業
12:30 - 13:30 話してみたい社員の方とランチ
13:30 - 14:00 人事の方と1on1面談
14:00 - 18:00 作業
18:00 - 18:10 アウトゲームチーム夕会
18:10 - 19:00 作業
19:00 退社

こんな感じでした。

「1on1面談」は「楽しく仕事やれてるか?」とか普段執務室では中々聞きづらい質問などをメンターさんや人事の方に聞ける面談で、3週間を通して合計5回ありました。 エンジニアリング的な部分や、もしアカツキに入社した場合の待遇の話など、かなり突っ込んだ内容をお聞きすることができたのでとてもありがたかったです。

また「話してみたい社員の方とランチ」はインターン生の特権でインターン期間中に毎週1, 2回会社が費用を出してくれて、話を聞いてみたい社員の方とランチに行くことができる制度です。(タダ飯最高) 自分は他者からアカツキに転職してきた方や、R&D部門でAR研究をしている方とのランチを設定して頂きました。 どの社員の方もとても優しくて、楽しくランチをしながら聞きたいことを聞かせて頂きました!

成果発表会

とうとうインターン最終日です。 ここで待ち受けているのが3週間で関わった社員の方やメンターさん、人事の方、同期のインターン生たちが集まる前で行う最終成果発表会です。 とはいっても学会とか研究室発表みたいな重苦しい感じではなくて、どちらかとLT会といった雰囲気で3週間の成果を話そう!といった感じです。

多分つらつら概要を語るよりもプレゼンを見て頂いた方がわかりやすいので、自分が発表したプレゼンを置いておきます。

speakerdeck.com *speakerdeckには代理で登録しています

インターンを振り返って

このブログを執筆しているのはインターン最終日なのですが、いざ振り返ってみると真っ先に出てくるのは「楽しかった」という感情です。(ありきたりですいません笑)

そもそも学生なので週5定時勤務もこのようなインターンで初めて体験するわけで「社会人って大変だなぁ」と痛感しながらも、その一員にあと1年半くらいで仲間入りする予定なわけで若干ツラくもあるんですが、それ以上にアカツキで働くという体験はこれまで経験してきたインターンと違って新鮮で”楽しかった”です。

また長期インターンとして参加したわけですが、モチベーションとしてはエンジニアリング的な部分を学ぶというよりも、アカツキでの働き方や実際に入社したときのことを想像することに重きを置いていました。 結果的にこの3週間でアカツキの良い面や悪い面、チーム内の人間関係なんかも含めて何となく見えてきたかなといった感じで、今後もしも入社した際にはどんな生活が待っていてどんなキャリアの積み方をしていけば良いのかなんかもざっくりわかったと思います。なので本当に有意義な3週間だったと思います!

あとはインターン中の給与もそうなんですが、待遇が他企業と比べてもとても良くて、企業単位でインターン生に投資(つまり未来への投資?)してくれている感じがして、より一層頑張ろう!という気持ちになりました。 (社員の福利厚生制度も一部利用させて頂けて、自分は「書籍購入制度」を使ってお高い技術書を3冊買って頂きました。本当にありがとうございました!)

と、この辺りで筆を置きたいと思います。 今後もアカツキでは毎年インターンが開催されると思うので、少しでも興味のある方は是非!おすすめです!


アカツキ就業型インターン9月の部

$
0
0

こんにちは!アカツキの就業型サマーインターンに参加させていただいたyutaroです。

当インターンでは15日間にわたり、サーバーサイドエンジニアとしてお邪魔させていただきました。

ここでは、その参加報告をさせていただきます。

実際に開発に携わったプロダクトは「八月のシンデレラナイン」です。担当することが決まってから初めてプレイしましたが、登場人物がカワイイですね!

初めて触れるジャンルでしたので、とても刺激的でした。

自己紹介

ざっくりまとめるとこんな人です↓

  • 大学では雑多な学問を学ぶ(情報系は独学)
  • 3年次からサーバーサイドの言語を学び始める
  • 2年次までは夜な夜な遊び散らかしていた

同じ時期に来ていたインターン生と比べると、エンジニアとしての経歴は浅い方ですね。

他の方は、かなり前からコードを書いていたようなので、ずっとビビっておりました。笑

今までに触れた技術

深掘りはせず、気になったものばかり学んでいたので、全てにおいて広く浅いです。

  • Web系の高級言語(PHP、Ruby、Python)及びその主要FW(Django、Rails)
  • Webフロントエンド及びその主要FW(React、Vue)
  • インフラ(AWS、GCP、Heroku)
  • iOSアプリ(Swift)
  • その他(Docker、CircleCI)

参加までの経緯

他社さんの逆求人イベントで、初めてアカツキさんとコンタクトを取りました。

志望後に面接 → コーディングテスト → 合否連絡 といった流れです。

参加前に面談が組まれ、そこで「どういった業務を行いたいか」の話し合いを行います。「インターン生がやりたいこと」を重視してくれるので、すごく嬉しかったのを覚えています。

他にも行ってみたい企業は複数あったのですが、以下のような理由で志望を決めました。

ゲーム業界の特徴的な実装をみてみたい

サマーインターンに参加する以前から、長期のインターンとしてサーバーサイドエンジニアの経験を積んでいました。

ただ、今までの経験で触れた分野は「メディア」のみです。Web業界の王道(?)分野ですね。

ゲーム業界はユーザー関連の情報がかなり多く、またトラフィックの変化も激しいので、また一味違う経験が積めるだろうと思っていました。

なので、「これを機にゲーム業界を経験したい」という思いで志望しました。

お賃金が高い

これは僕の主観ですが、他のインターン生にも少なからずある下心だと思います。笑

高い給与を提示している企業で、「僕自身が通用するのか」試してみたいチャレンジマインドが奮い立たせられました。

相当レベルの高い学生しか志望しないと思っていたので、「選考が通ればラッキーだな」ぐらいの感覚でしたね。受かってよかった...

何をやったのか

大きく分けて3つのタスクを行いました。

  • ランキングマッチ上位者の詳細なポイント取得履歴を出力するRake Taskの追加
  • 管理者画面にOne Loginでログインできるようにする
  • 消費元気n倍で試合をし、報酬をn倍にする機能の追加

以降は、これらをそれぞれ具体的に記述していきたいと思います。

ランキングマッチ上位者の詳細なポイント取得履歴を出力するRake Taskの追加

長期のインターンで入社時に毎回行なっているのは「簡単なタスクを片付けてみる」です。

理由としては↓

  • 入社した直後はあまりコードに慣れておらず、大きなタスクをこなすのは非常にストレスを感じる行為だと考えている
  • メンターが僕自身の実力をまだ把握できていない

なので今回も例外なく、単純なタスクを行いました。


ハチナイの試合形式として「ランキングマッチ」というものがあります。

これは、全国のユーザー同士が一定期間内に試合を行い、順位を競い合う形式となっているのですが、ランキング上位者には特別な報酬が与えられます。

現状の方法として、ランキング上位者を算出するために、エンジニアの方が毎回手動でスクリプトを書いています。しかし↓

  • 毎回スクリプトを書くのがめんどくさいので、楽にできないか
  • 詳細なポイント取得履歴も同時にみて、より妥当性を検証できるようにしたい

このような要望があったので、双方を満たすようなスクリプトを追加しました。

着手するまでRailsのRakeの存在すら知らなかった僕ですが、すでに手打ちで書かれたコードが存在していたので、すんなり実装できました。

One Login機能の追加

着手前の段階で、「次のタスクは何をやるか」を決めるミーティングが開かれました。

というのも、具体的なタスクは入社後に決める方針だったからです。

僕はせっかくなので、「難しそうなやつをやってみたいっす!!」と申し出たところ、要望通りのモノが来ました。笑


ハチナイ(サーバーサイド)の管理画面へのアクセスはIP制限をかけてはいますが、ログイン手法はかなり単純で「これってセキュリティ的にあまり良くないよね??」という話は前々からあったようです。

ただ、みんなやりたがらない領域だったので、僕に白羽の矢が立った(というか、僕が立たせた)。といった経緯です。

アカツキでは全社的にOne Loginを利用しています。One Loginについてざっくり説明すると↓

  • SSO(シングルサインオン)用のサービス
  • One Loginにログインするだけで、他のサービスへのログインが不要になる

こんなところです。

つまり今回のタスクは「管理画面へのログインにOne Loginの機構を取り入れる」というものになります。

ひたすらインプット

着手時点で、「そもそもOne Loginってなんですか??」という状態だったので、最初のうちはとにかく調べまくりました。

認証方式の実体はSAMLだったことや、実際に組み込んだ際の認証フロー、One Login側の操作方法等、調べていくうちに実装のイメージが明確となっていきました。

幸いにもOne Loginの方々が、Rails向けのライブラリをすでに実装してくださっていたので、比較的ラクに実装できたと思います。

既存のライブラリとうまく組み合わせる

一番ツラかったのが、この部分ですね。

管理画面にはActive Admin、ユーザー管理にはdeviseというライブラリを用いていたのですが、既存の実装への影響を最小限にするためには、これらのライブラリとOne Login用のライブラリをうまく組み合わせる必要があります。

ライブラリ同士の組み合わせで情報を探そうとすると、そもそも全体数が少なく、情報が古かったりして、トライ&エラーの繰り返し...

根気よく粘りつつ、なんとか実装することができました。

消費元気n倍で試合をし、報酬をn倍にする機能のプロトタイプ作成

次は「ゲーム会社ならではのタスクを行ってみたい!!」と申し出ました。

前回行ったタスク(One Login機能)は、他の業界でも行うことができるものだったからです。


「自分で課題を見つけて、それを解決する機能を実装してみて」と言われたので、ハチナイで利用される元気を数倍にし、報酬をその倍数分増加させる機能をサーバー側で実装してみました。

プロトタイプですので、リリースされるかは未定です

イベント等で同じステージを周回するのに、毎回同じ操作を行うのはUX的な観点でツラいだろうと思い、それを解決する機能となります。

どの要素に対してn倍を適応するのか、逐一メンターの方に相談しつつ、実装を進めました。

ハチナイの中でそこそこコアな部分の実装に触れることができ、非常に楽しかったです。

学べたこと

ゲーム開発(サーバーサイド)の実情

ソーシャルゲーム特有なのかもしれませんが、仕様の微妙な変更が多いだろうなと感じました。

仕様からみたら微妙な変更でも、実装から見ると大きな変更を伴うケースはあるはずです。

実装中、聞いた方が確実に早いときはメンターの方に聞くのですが、現在利用されていないコードもいくつかあるそうで...

そういったところから、ゲーム開発のツラそうな一面を垣間見ることができました。

フラットなチーム

所属するチームや担当するプロダクトによって異なるかもしれませんが、どの方もアグレッシブに働いている印象を受けました。

僕自身、エンジニアを目指していない時から4社ほど長期インターンを経験していますが、このような組織は初めてです。

すごく記憶に残っている一面は、僕のタスクの仕様を決めるために、メンターや他の社員とお話をしている時ですね。雑談ライクに1~2人の方とお話をしていたのですが、次々といろんな方が加わってきて、「もはや会議室とかで行った方が良いのでは...??」と思える人数となってしまいました。

どの方もすごく楽しそうに話されていて、良い人ばかりでした。

飲み会にも参加させていただいたのですが、終始笑いが絶えず。「あ、とてもいいチームなんだな」と思いました。

また、このようなチームを将来作れる人になりたいとも思えました。これは未来の宿題ですね。

感想

就業型の短期インターンシップは初めてで、アルバイトと同じものだと考えていましたが、それにとどまらない体験をさせていただけたと思います。

また、貢献度としてはまだまだ低い結果だと思うので、短期間でも成果を出せるよう、これからも精進していきたいと思います。

環境変数を設定するだけでRuby on Railsサーバが10%高速化する(かもしれない)話

$
0
0

この記事は Akatsuki Advent Calendar 2019 1日目の記事です。

はじめに

アカツキでは Ruby on Rails を使ったゲームサーバを開発・運用しています。ゲームの体験を向上するために、レスポンスタイムは一つの重要な要素となるため、種々のパフォーマンスチューニングを行なっています。今回はその一例として、環境変数を1つ設定するだけで、あるAPIのレスポンスタイムが10%も改善した例をご紹介します。

TL;DR

多数の時刻を含むレコードを扱う Ruby on Rails サーバでは、 TZ環境変数を設定することで、デフォルトタイムゾーン設定ファイル /etc/localtimeへのアクセスが減り、高速化が図れるかもしれません。

効果は Timeオブジェクト1個あたり数μsの短縮といったオーダーですが、チリも積もれば山となり、数千個のレコードを処理するAPIではレスポンスタイムが10%近く改善する例もありました。

f:id:NeoCat:20191105185907p:plain

APIのレスポンスタイムが10%も改善!

きっかけ

ある日、モバイルゲームのAPIサーバの負荷テストを大きめのインスタンスサイズのサーバで実施したところ、小さい構成のサーバでは問題なかったAPIのレスポンスタイムが、なぜか数秒から数10秒と、とんでもなく遅くなるという事象が起きました。
その時のシステムの状態を topを確認してみると、CPU使用率の user は数%なのにも関わらず、sys が 95% といった高い値を示しており、アプリケーションではなく OS (Linux) 周りの問題であるようでした。

環境

問題が発生した環境は、AWS EC2の c5.9xlarge インスタンス (36 core) の上にDockerで ruby:2.5 コンテナ(Debian Stretchベース)を立て、その中でRailsサーバーを動かしており、そこに多数並列でHTTPリクエストを送り込んでいた、という状況です。

Railsのサーバとして、unicorn が複数プロセス動いており、各プロセスはシングルスレッドで動作する構成です。つまり、スケーラビリティの問題の原因になりやすい Ruby の GIL (グローバルインタプリタロック) のせいではありません。

詳しく分析してみる

このセクションでは、 Linux カーネルや Ruby の動作を調べた内容を説明していきます。低レイヤーに踏み込む話になるので、早く結果を知りたいという方は、次のセクションの「対策とおまけ効果」まで読み飛ばして構いません。

プロファイラをかけてみる

こういったときには、どこでCPUが使用されているかをプロファイラで調べてみると有効です。そこでまず、 Linux のプロファイラである perfを使用してみます。

問題発生中に sudo perf top -gを実行すると、CPU が使われている場所のプロファイルが C の関数単位でリアルタイムに表示されます。

以下は95%以上を占める高い __xstat64から下位の関数を展開していったところです。なお __xstat64はファイルの変更日時などの情報を取得するglibcの関数です。

Samples: 30K of event 'cycles', Event count (approx.): 32976632083
  Children      Self  Shared Object       Symbol
-   95.21%     0.02%  libc-2.24.so        [.] __xstat64
   - __xstat64
      - 95.04% entry_SYSCALL_64
         - do_syscall_64
            - 94.14% __do_sys_newstat
               - 94.03% vfs_statx
                  - 92.21% filename_lookup
                     - 92.14% path_lookupat
                        - 66.85% link_path_walk.part.39
                           - 63.60% walk_component
                              - 22.86% lookup_fast
                                 - 22.75% __d_lookup
                                    + _raw_spin_lock
                              - 21.11% path_parent_directory
                                 - 19.89% dget_parent
                                    - lockref_get_not_zero
                                       + _raw_spin_lock
                                 + 1.23% dput
                              - 19.53% dput
                                 + 18.02% _raw_spin_lock
                                   1.38% lockref_put_return
                           + 3.00% inode_permission
                        - 22.98% walk_component
                           - 20.49% dput
                              + 19.17% _raw_spin_lock
                                1.14% lockref_put_return
                           + 2.41% lookup_fast
                        + 1.70% trailing_symlink
                        + 0.57% terminate_walk
                  + 0.88% path_put


詳細は割愛しますが、path_lookupat関数を始め、主に Linux カーネル内のファイルパスを辿る関数群で時間を消費しているようです。さらに辿っていくと、主要因としていろんなところで _raw_spin_lockが出てきました。これは、 Linux カーネルのスピンロック (他のCPUと排他が取れるまで無限ループしながら待つロック方式) の関数で、1つのコアしか同時に処理を行えません。何らかの同じリソースの取り合いが全コアで発生し、コア数が多い分、より激しく競合が発生した結果、アプリケーションの処理が遅々として進まないほどに性能が低下してしまったと考えられます。

では一体何を奪い合っているのでしょうか?

システムコール呼び出しを見てみる

アプリケーションがこの時何をしているのかを調べてみます。

strace -p <rubyのPID>

を実行して何のシステムコール呼び出しているかを調べてみると、どのプロセスも、

stat("/etc/localtime", ...)
stat("/etc/localtime", ...)
stat("/etc/localtime", ...)
...

と、ひたすら /etc/localtimeというファイルに対して statシステムコールを呼び出していることがわかりました。同じファイルに対して全コアでロックを取りあっているため、激しく競合が発生しているようですね。

このファイルは、システムのデフォルトのタイムゾーン情報を格納するものです。

ファイルと言いましたが、正確にはrubyコンテナではシンボリックリンクになっています。

UTCをデフォルトタイムゾーンとして使用していますので、リンク先は /usr/share/zoneinfo/Etc/UTCを指しており、さらにこれもシンボリックリンクで ../UCTを指しています。

また同様に ltraceをアタッチしてみると、tz_set, localtime_rなどの時刻関連の関数がひたすら呼び出されていました。この中で先ほどの statシステムコールが呼び出されています。

statシステムコールということは、このファイルの内容を読んでいるわけではありません*1。もしかしたらいつの間にかデフォルトのタイムゾーンが前回から変更されているかもしれないので、再読み込みか必要かどうかを調べるために、更新日時を調べているのです。( → glibc__tzfile_read() )

Ruby on Railsはその時何をしていたのか?

では、なんでこんなに localtime_r が呼ばれているのでしょうか?
実は、負荷テストのシナリオの中に、数千件のレコードを MySQL から取得して読み込む API がありました。これらのレコードにはそれぞれ数個の日時が含まれていました。加えて、ActiveRecordの慣例に従って、各レコードには created_at, updated_at カラムがあり、作成・更新時刻を格納しています。
そしてこれらを ActiveRecord が Time(WithZone) クラスとしてインスタンス化する際に、 localtime_r が呼び出されていたのでした。

f:id:NeoCat:20191105191132p:plain

読み込んだレコードには日時が多数含まれていた

localtime_rTime.newごとに1回呼ばれるだけではなかった

しかも、 localtime_r は Time のインスタンス化で一回呼び出されるだけではありませんでした。

strace ruby -e 'puts; p Time.new'

などと実行してシステムコールの呼び出され方を見てみると、以下のようになりました。*2

  • Time.new → statが1回呼ばれる
  • Time.new(2019,7,1,0,0,0,"+09:00") → なぜかstatが2回呼ばれる
  • Time.new(2019,7,1,0,0,0) → なぜかstatが4回くらい呼ばれる(timezoneを取得するため?)

Ruby の time.cを見てみると、 find_time_t関数にてシステムの localtime_rに指定時刻の2時間前後の時刻を与えて、何が返ってくるかを調べています。これはどうやら、夏時間の切り替わりの際には同一の時刻が2回ある場合があるので、そういうときに必ず決まった側を返すための処理のようでした。

それにしても、このコードには多数の #ifdefがあり、Ruby を様々な環境で動かすための苦労が窺われます……。

対策とおまけ効果

man tzsetを見てみると書いてあるのですが、 環境変数 TZに値が設定してあれば、 /etc/localtimeは読まれなくなり、この問題も発生しなくなります。
そこで、例えば TZ=UTCと設定すると、 /usr/share/zoneinfo/UTCが初回だけ読まれ、それ以降は stat等のファイルアクセスはなくなります。

これは今回きっかけとなった負荷テストで見られたような大規模サーバでのスケーラビリティの解決はもちろんですが、たとえ小さなサーバであっても、システムコールの発行自体がなくなることで性能向上を期待できます。

具体的に、先ほどのruby:2.5.1コンテナ内で、シングルプロセスのみで試してみましょう。

$ irb
irb(main):001:0> t = Time.now; 100000.times { Time.new(2019) }; Time.now - t
=> 1.027663412

# 環境変数を設定
irb(main):002:0> ENV['TZ'] = 'UTC'
=> "UTC"

irb(main):003:0> t = Time.now; 100000.times { Time.new(2019) }; Time.now - t
=> 0.135658217

なんと7〜8倍の高速化です!

Linuxディストリビューションによっては、 /etc/localtimeはシンボリックリンクでなく実ファイルであることもあります。
例えばAmazon Linux 2では /etc/localtimeは実ファイルとなっていますが、このケースでも測定してみました。

irb(main):001:0> t = Time.now; 100000.times { Time.new(2019) }; Time.now - t
=> 0.57196811

TZを設定すると先の例と同程度の時間になり、約4倍の改善となりました。シンボリックリンクのパスを辿らなくて済む分、軽微な影響となるようです。

もちろんこれは Time.newのみの倍率であって、1回1回は高々数μsといったオーダーの効果です。しかし、数千レコードを扱うようなAPI*3だと、積もり積もってAPI全体のレスポンスタイムが10%程度も改善するケースが実際にありました。

まとめ

Ruby on Railsで大量の時間を含むレコードを扱う際、 TZ環境変数が設定されていないと、Timeオブジェクトの初期化のたびに複数回 /etc/localtimeに対して statシステムコールが呼び出されるため、性能が低下します。特に /etc/localtimeがシンボリックリンクである場合や、コア数の多いサーバ環境ではカーネル内のスピンロックのために影響が顕在化しやすくなります。この現象は特にRuby 2.5までにおいて顕著です。

TZ環境変数を適切な値に設定することで、これを避けて、時刻を扱う処理を高速化することができます。

 

今後のRubyへのフィードバック

さて、これで本問題は一段落なのですが、 こうした手間をかけなくともRuby自体で高速に動作してくれたら嬉しいですよね。アカツキに技術顧問として加わって頂いた小崎資広さんに、この現象についてRubyのアップストリームで根本的な改善ができるのではないかと、コミュニティで議論していただいています。

 詳しくは別の記事でご紹介したいと思いますので、乞うご期待。

 

*1:ファイルの内容は、起動直後に1度だけ読み込まれています。

*2:なお、この挙動はRuby 2.6で少し変更され、stat(2)の呼び出しは 1回となっているようです。

*3:ゲームの運用を続けていくにつれてデータが増えていき、数千件のレコード処理の読み込みが必要になることはままあります。

運用フェーズのプロダクトのCocos2d-xバージョンを3.2から3.17に一気に上げた時の知見

$
0
0

この記事は https://adventar.org/calendars/3952 3日目の記事です。

 

はじめに

 こんにちは。クライアントエンジニアのRiyaaaaaです。

 私が担当しているプロダクトではゲームエンジンとしてCocos2d-xを採用しています。リリース当初のバージョンは3.2で、それ以降4年間1度もアップデートされることはありませんでした。しかし、今年、関係各所の調整の結果、とうとうCocos2d-xを最新バージョンまで上げる大規模な改修バージョンのリリースに成功し、こうして記事の執筆に至った次第です。

 断っておきますが、執筆者である私はCocos2d-xバージョンアップ作業において、先行的な技術調査、最低限のゲームを動かすためのコンパイル作業や基幹システムへの修正等しか行なっておりません。Cocos2d-xバージョンアップという大きな改修は、多くのエンジニアの協力あって実現しました。この場を借りてお礼申し上げます。

 

Cocos2d-xバージョンアップを実行に移した経緯

 品質向上のためにもゲームエンジンのバージョンを上げたいという話は以前から上がっていましたが、プロダクトの視点から見ると、リスクの割にベネフィットが薄い点が否めません。そのため、問題意識はありながらも、開発項目に具体的に上がることはありませんでした。

 しかし、スマホアプリ業界に2つの大きな激震が走り、Cocos2d-xのバージョンアップはほぼ必須というところまで追い込まれてしまったのです。業界の方ならよくご存知でしょう。

 1つ目は、2018年6月ごろAppleが発表した、iOSのOpenGL ESの利用をdeprecatedとする発表です。つまり、将来的にOpenGL ESはサポートされなくなるので、Metalへ移行すべしというお達しです。

 2つ目は、2019年1月ごろGoogleが発表した、Google Playアプリは2019年8月をもって64bit対応を必須とする発表です。これはiOSの件よりも深刻でした。なぜなら、数年後といったスパンではなく、残り半年強という期限で対応を迫られていたからです。*1

 この2つの発表が決め手となり、ついにプロジェクトリーダー、版元様、ディレクター陣の合意を取り、現場のエンジニア、テスターなどのスタッフの大規模なリソースを注いでの、バージョンアップ作業が始まりました。

 

バージョンアップ作業黎明期、とりあえずゲームが動くまで

 さて、私はというと、AppleのOpenGL ESのdeprecated化の発表より、秘密裏にCocos2d-xバージョンアップ計画を進めておりました。私は将来的に必ず、ゲームエンジンのバージョンアップを迫られる日が来るという直感があったからです。*2しかし、マイナーバージョンを2から17に上げるというアグレッシブな改修には、そもそも「可能である」ことの証明が必要です。そのため、開発の空き時間などを利用し、地道にローカルでバージョンアップ作業を進めていました。

 もちろんコンパイルすら通らないところからのスタートです。gitで専用のworkspaceを切って、毎日少しずつコンパイルエラーを減らしていきました。ようやくコンパイルが通ったところで、起動時クラッシュに直面。そこから数多くのファイルシステム、ネットワークシステム、その他クラッシュを引き起こす不具合を修正し、「とりあえず動く」状態に持って行きます。

 

Cocos2d-xバージョンアップの開発本格始動

 そして2019年初ごろ、Googleの64bit対応必須化発表の折、「Cocos2d-xアップデートなしには対応は不可能」という見解をエンジニアチームは出します。ndk-buildのabiFilterにarm64-v8aを追加する、そんな単純な話ではありません。特に問題になるのが、Third-pirty製静的ライブラリです。アプリ側のライブラリはもちろん、Cocos2d-xが依存する外部静的ライブラリもほぼ全てが64bitサポートされていませんでした*3。エンジンはアップデートすることなくライブラリだけアップデートする? そんなことが不可能なのは火を見るより明らかでした。

 しかし功を奏して、私の手元にはとりあえず一通り動作している最新Cocos2d-xリプレイス済みバイナリがありました。もちろん、遷移するだけでクラッシュする画面もあれば、どこもかしこもレイアウト崩れが起きまくっていましたが... 「バージョンアップ、イケると思います」

 こうして、Cocos2d-xバージョンアップ計画は、新規開発チーム全体を巻き込んで始まったのでした。

 ちなみに、私はCocos2d-xバージョンアップのメイン開発チームには加わっていません(!?)。弊プロダクトは定期的に大規模なイベントを開催しておりますが、その重要機能の開発に追われていたからです。残念なことです。しかし、ちょうどその時に社内から異動してきたエンジニアの方が低レイヤーなんでも御座れの超ベテランエンジニアだったので、代わりに担当していただき、結果的に素晴らしいアウトプットになりました。最後に紹介しようと思いますが、単なるCocos2dxアップデートに留まらない劇的なビルド環境改善に取り組んでくれたのです。
 

起こったバグ、修正方針まとめ

 以降は、事象、修正方針ベースで項目化して紹介していきます。あまりにも量が膨大だったため、漏れがあるかもしれませんが、参考にしてください。免責事項として以下に紹介する内容はあくまで弊プロダクトの修正方針であり、全てのプロジェクトで動作を保証するものではありません。また、私が実際に修正したわけではないものも多くありますが、誰かの役に立つことを願って、代理として紹介させていただきます。

1. ファイルシステムの大規模なコンフリクト

 弊プロダクトはCriFsというCRIWARE様のファイルシステムを採用しています。そのため、Cocos2d-xのFileUtilsをはじめとするファイルシステム関連クラスには独自拡張を大量に含んでおり、バージョンアップの際に大規模なコンフリクトを起こしました。気合で直しました。初っ端から参考にならない事例で申し訳ない... 

2. HttpClientクラスのレスポンスコードの取り扱いの変化

 元のcocos2d/cocos/network/HttpClientには、responseCodeが200以上、300未満ではない場合、タスクの失敗とする実装がありましたが、3.17のHttpClient-apple/HttpClient-androidクラスでは、それに該当するコードが無くなっていました。つまり、コールバックの取り扱いが変化していたのです。すると、アプリ側でFailureコールバックとしてハンドリングしていた部分が途端に動かなくなりました。

 そのため、挙動を元に戻すためにエンジンに手を加えました。例えば、HttpClient-apple::processTaskの末尾に3.2当時のと同等動作のコードを加えます。

staticint processTask(HttpClient* client, HttpRequest* request, NSString* requestType, void* stream, long* responseCode, void* headerStream, char* errorBuffer)
{

  // 中略  + if (!(*responseCode >= 200&& *responseCode < 300)) {
  +   return0;
  + }

  return1;

}

androidも同様です。

3. OpenSSL EVP_CIPHER_CTXのインスタンスの確保方法の変化

 aes_encrypt/aes_decryptに使う暗号情報のコンテキストの型EVP_CIPHER_CTXが、動的確保に変化しました。具体的には、EVP_CIPHER_CTXは前方宣言のみ提供され、今までのようにスタック変数として宣言することができなくなり、EVP_CIPHER_CTX_nex(), EVP_CIPHER_CTX_free()インターフェースで動的確保、解放することを要求されるようになりました。

4. FileUtils::fullPathForFileNameのファイルが見つからなかった時の挙動の変化

 以前の実装ではファイルが見つからなかった時の挙動が「ファイル名」を返す、という挙動でしたが、空文字列が返るように変更されています。妥当な修正ですが、以前の挙動を前提としたコードがあったので元に戻しました。

5. Spriteのレンダー用のコマンドクラスがQuadCommandからTrianglesCommandへ変化

 独自のSpriteを継承した描画クラスが、draw関数をオーバーライドして実装していましたが、その辺りが旧_quadCommandメンバにアクセスしていたため、_trianglesCommandに変更しました。インターフェース等は抽象クラスが同じなため変数差し替えだけで動作しています。

 

6. RefPtrが不完全な型を受け付けなくなった

 RefPtrにコンパイル時型検査が追加されました。

static_assert(std::is_base_of<Ref, typename std::remove_const<T>::type>::value, "T must be derived from Ref");

 std::is_base_ofは完全型を要求するので、既存のコードで前方宣言のみされた型をRefPtrとして定義しているところのコンパイルが通らなくなったのです。これはこのstatic_assertをコメントアウトするだけでも解決しますが、アプリ側の該当箇所が1箇所だったのでそちらを修正しました。

class A {

  class B {

    cocos2d::RefPtr<A> _ptr;

  };

};

 
 このように、インナークラスが外側のクラスのRefPtrを保持するような設計でしたが、これだとBの定義段階ではAは不完全型なので、インナークラスをやめて分離することで解決しました。

7. Scene::_childrenにカメラインスタンスが追加され、_childrenのインスタンスのインデックスが変化した

 これはかなり頭を悩ませました。なにせクラッシュを引き起こす上に、不正なstatic_castを起こして意味不明な場所でバグるのです。そんなコードが何十箇所も!!

 これははっきり言ってお見せするのも恥ずかしい典型的なアンチパターンのコードなのですが、画面の構築順序上「このインスタンスはchildrenのX番目にある」という危なげな根拠による、childrenメンバへのインデックスによるアクセスコードが大量にあったのです。
  

// X番目のChildはHogeクラスだからstatic_castするぜauto hoge = static_cast<Hoge*>(view->getChildren().at(X));


 これは最悪なコードです。まず前提としてこんなコードを書くべきではありません。ですが、開発黎明期に書かれたレガシーコードには多くのこういったコードが含まれていました。*4しかも、たちの悪いことに、static_castを使用しているのです! 嗚呼、これがdynamic_castだったなら、即時にnullptrアクセスで落ちるのに。しかし、static_castは違うインスタンスポインタだろうが問答無用でキャストします。その結果、アクセスの仕方によって様々な挙動を見せます。これは低レイヤー言語の趣深い挙動ですね。クラッシュすればいい方です。

これはgrepで該当コードを探し出してひたすら修正しました。修正方針はそこの文脈で都度判断。ですが、原因の多くはSceneクラスを定義する時、Layerを継承して空のSceneにaddして返す、という古きCocos2d-xのお作法によるものが多かったです。つまり、あるSceneをcreateした時、返ってくるinstanceは目的のクラスではなく、空のSceneクラスだったのです。そのため、childrenから目的のクラスインスタンスを探すコードが多くありました。

auto scene = MyScene::create();

auto myClass = static_cast<MyScene*>(scene->getChildren.at(0)); // 最新バージョンでは、0番目はカメラのインスタンスのためNG

 
 実際にはSceneクラスを定義する時にそんな面倒なことをする必要はなくシンプルにSceneを継承すればいい話です。公式のサンプルコードも、すでにその黒歴史を修正しています。
don't use Layer by minggo · Pull Request #17048 · cocos2d/cocos2d-x · GitHub


上記修正と同じことを既存のSceneクラスに取り込めば、大体は解決しました。

 

8. いくつかのクラス、関数が引数にnullptrを受け付けなくなっていた

CCLuaStack::luaLoadBuffer、CCProgresTimer::initWithSpriteなどです。大抵は呼び出しもとでnullptrハンドリングして終了です。

9. setColorを使ったグレーアウトが動作しなくなっていた

setColorの内容を子供に伝播させるにはNodeのcascadeColorEnabledをtrueにしなくてはならないのですが、様々なCocos2d-x関連クラスのデフォルト値がfalseに変更されました。そのため、setColorを使ったグレーアウトなどの処理が途端に動かなくなったため、急ぎ様々なViewクラスでsetCascadeColorEnabledを呼び出して止血しました。現象次第はシンプルなのですが、如何せん影響範囲が広く、一つの原因に紐づくバグチケットの数がかなり多かったですね。。

10. Androidで一部通信のstyleがunknown_formatになる

 Cocos2dxはAndroidでHttp通信にcurlではなくjava.net.HttpConnectionを使うようになりました。この時、デフォルトヘッダの挙動が変わり Accpet: */* が追加されなくなりました。この時、サーバーサイドのAPIのroutesでレスポンスのMIMEタイプが指定されてないときの挙動の違いにより、レスポンスの解釈がiOSとAndroidで変化するようになってしまいました。というか、基本的にはjson以外受け付けてないんですけどね...

これはアプリ側のリクエストクラスでデフォルトヘッダの差を吸収するようにしています。サーバーサイドでも必ず全てのAPIのroutesで{format: json}を指定するようにしてもらいました。

11. Android4.4未満で通信がバグる

お気の毒ですがCocos2d-x 3.17では4.4未満をサポートしなくなりました

13. ImageViewのアルファチャンネルが無効になるバグ

これはシンプルにcocos2d-xのバグで、ImageViewを2回同じファイル名でloadTexuteを呼び出すとアルファチャンネルが効かなくなるバグです。
これは担当エンジニアがすでに本家にプルリクを送っており、マージされました。
github.com

14. Labelの末尾の改行が無視されなくなった

バグだったのかはわかりませんが、末尾の改行がちゃんと適用されるようになり、それによりレイアウト崩れが発生していました。

15. 9sliceを有効にしたImageViewが特定条件下でバグる

fix_bottomとfix_topが実際の画像の縦幅を超えるというデザインバグがあり、なぜか今まで動いていた状態だったが、バージョンアップによって普通に表示が崩れるようになってしまいました。

16. SceneをaddChildしているところが動かなくなった

そんなことをしてはいけません。おそらくVisittingCameraとかそういうアクティブなシーンのカメラみたいな概念が追加された影響で動かなくなったのかと。replaceSceneやpushSceneを使いましょう。

17. 勝手にボタンがグレーアウトする

ui::Button::setEnableにfalseを渡すと、Buttonクラスの内部で勝手にグレーアウトするようになりました。仕様ということにしました。

18. ScrollViewでクリッピングがバグる

Cocos2d-xのバグです。社内で独自のパッチが当てられましたが、本家では別の形で修正されているようでしたので、そちらの方のリンクを貼っておきます。
https://github.com/cocos2d/cocos2d-x/pull/20352

19. LabelLetterを使っているところでバグる

Cocos2d-x側のバグです。本家にPRを送りました。
Fix: LabelLetter::isVisible always returns false by Riyaaaaa · Pull Request #18975 · cocos2d/cocos2d-x · GitHub

20. Labelの返すgetContentSize().hegihtが半分になっている

これはかなーーり大変な挙動の変化でした。半分になった、というよりはもともとバグっていて2倍になっていたのが元に戻った、というのが正しいです。
しかし、これを前提として構築されたレイアウトがとんでもなく多く、ハードコーディングされたオフセット、italicでアフィン変換された時の位置、様々な箇所に止血対応が入りました。heightサイズバグを再現するLegacyLabelクラスの爆誕などもしました...。この挙動の変化の影響で、とんでもない数の画面の表示がおかしくなっていたのです。。。
全体の工数の10%くらいは、この不具合の対応に追われていたような気もします。笑
 

21. Cocos2d-xのLuaBindingの強化

3.17では、任意のCocos2d-x APIをLuaから呼び出せるように、大規模な改修がされています。ただし、その影響でエンジンのいたるところに、ScriptBindingが有効な時にのみ走る処理が追加されています。弊プロダクトでは、Cocos2d-x Lua-bindingを使用していません。これらの処理は無用なオーバーヘッドなので、CC_ENABLE_SCRIPT_BINDINGを無効にしました。*5


こんなところでしょうか。数が多すぎて網羅できた気がしない...。

おわりに

Cocos2d-xバージョンアップの歴史を一気に振り返りました。この記事を執筆する上で、大量のバグチケットやプルリクを漁り、懐かしい気持ちになりながら、当時の慌ただしさを感じていました。どうせエンジンをアップデートするなら、他のシステムも改善したいというエンジニアの要望もあり、同バージョンには多くのシステム改善がされました。

・Android NDKビルドシステムのndk-buildから、CMakeへの移行
・サウンド系システムの大改修
・CocoaPodsの導入
・様々なThird-pirty製ライブラリのアップデート
・その他パフォーマンス改善など

トータルで半年以上の開発期間(検証含む)を要したこのバージョンは、無事Android 64bit対応期限に間に合い、本番環境で深刻な不具合を出すことなくリリースされました。機能追加を含まないバージョンを、半年以上も開発し続けることができるリソースがチームにあったことはとても喜ばしいことです。これが実現できたのは、現場の開発者だけではなく、マネージャー、運用サイドの担当者などの多くの方のご協力があってのことです。

ちなみにOpenGL ESが本格的にiOSでサポートされなくなり、Metal対応が必須になった時、きっとCocos2d-x v4へのバージョンアップが迫られることでしょう...。メジャーバージョンアップには、今回の規模を遥かに超える改修が必要になりそうです、が、このチームなら乗り越えられる気がしています。多分。

ここまで読んでくださりありがとうございました。


 

*1:しかし、この記事を執筆する上で再度調査していると、実際には2017年には告知されており、弊プロダクトは技術に対するアンテナが細かったために急な対応を迫られてしまった説があります。反省ですね。

*2:この時は、まだAndroid 64bit対応の発表はされていませんでした。

*3:iOSライブラリだけは、64bit対応が2014年くらいに実施されていたので対応されていました

*4:もちろん私はこんなコード書きません。

*5:なぜ有効になっていたのかというと、Cocos2d-xとは関係のないオリジナルのLuaEngineのバックエンドに、CCLuaEngineを使用していたためです。本来はtolua++等のピュアなLua Bindingライブラリを使用すれば必要のない設定でした。

RailsでTZ環境変数を設定するハックを不要にした話

$
0
0

TL;DR

環境変数を設定するだけでRuby on Railsサーバが10%高速化する(かもしれない)話』  でRailsを高速化させる素晴らしいハックが紹介されましたが。いまや有効なハックではなくなりました。

 

TZハックさん、ながい間(2日間)おつかれさまでした。

 

はじめに

 アカツキさまで技術顧問をさせていただいている小崎です。

 

このエントリは『環境変数を設定するだけでRuby on Railsサーバが10%高速化する(かもしれない)話』をRubyコミッタが読んだらこうなったというアンサーソングになっています。合わせてお読みください

 

TZ環境変数でTime.newが10倍近く速くなるのは素晴らしい発見ですが、コミッタとしてはTZなしでも速くなって欲しいなと思いました。だってめんどうだし。

 

現状分析

まず問題のテストプログラムを軽く分析してみましょう

 

% strace -c ruby ../t.rb
% time  seconds     usecs/call  calls     errors    syscall
------ ----------- ----------- --------- --------- ----------------
99.86     4.248080          11    400076         1 stat
0.03      0.001304           3       458       319 openat
0.02      0.000786           2       362         7 lstat
0.02      0.000682           4       184           read
0.01      0.000407           3       141           close
0.01      0.000390          11        34           mmap
...

Time.new 10万回に対し、statが40万回、つまりTime.new1回あたり4回のstatが呼ばれていることが分かります。

 ltraceでも確認してみます

% ltrace -c ./miniruby ../t.rb 
% time   seconds    usecs/call     calls function
------ ----------- ----------- --------- --------------------
 30.97   47.608101         158    300000 tzset
 25.24   38.804739         129    300000 localtime_r
 16.28   25.027547         120    207016 malloc_usable_size
 10.51   16.163021         161    100000 mktime
 8.31    12.781192         123    103912 calloc
 8.26    12.698693         123    103204 strlen
 0.18     0.282174          77      3645 memcpy
 0.13     0.204240          76      2674 malloc
 0.08     0.129064          80      1601 memcmp
..

 

おや?tzset(), lcoaltime_r() 呼び出しが30万回しか呼ばれておらず数が合いません。

 が、よく訓練された低レイヤー開発者は glibcの__tzfile_read() が呼ばれるのはtzset(3)が呼ばれたときだけだけども、tzset(3)はmktimeが呼ばれたときにも内部的に呼ばれていることを知っています。

つまり、Time.newするたびに、tzsetを3回、mktimeを1回呼んでおり、それがstat 4回につながります。

だいたい計算があってきました。

 

余談

 

tzset()とはTZ環境変数にもとづいてlibcのタイムゾーン初期化する関数ですが、TZ環境変数が未設定の場合は処理系依存です。

glibcの場合は /etc/localtimeのファイルに従いタイムゾーン情報を使って初期化します(まあ妥当ですよね)。ところがお節介なことに、glibcは /etc/localtime があとから変更された場合に備え、tzset()が呼ばれるたびに、 /etc/localtimeをstatして変更がないかチェックし、変更があれば再初期化するという実装になっています。

ほとんど使い道のないお節介のために遅くなっているのですから、余計なことをしてくれたものです。

 

 

localtime_r 問題(すでに解決済みでした)

 

さて、Rubyのtime.c をみると

#define LOCALTIME(tm, result) (tzset(),rb_localtime_r((tm), &(result)))

のような行があり、ようするに localtime_r()を呼ぶときには毎回 tzset()を呼んでいることがわかります。

これはlibcのマニュアルの

https://linuxjm.osdn.jp/html/LDP_man-pages/man3/ctime.3.html

POSIX.1-2004 によると、 localtime() はあたかも tzset(3) が呼ばれたかのように振舞うことが要求されているが、 localtime_r() にはこの要件はない。 移植性が必要なコードでは、 localtime_r() の前に tzset(3) を呼び出しておくべきである。

 

の記述に従っているためです。

・・・・と言いたいところですが、別に毎回呼べとは書いてありませんね。最初に一回だけ呼べばいいのでは?

 

とここまで調べたところで、実はこの問題はRuby 2.6で解決済みであったことに気がつきます。一番悔しいパターンです。元blogにあわせて Ruby 2.5で調査していたための不覚でした。

修正は以下のコミットです。

 

commit 4bc16691279e98ecdb3e19ff23902be671d46307Author: nobu <nobu@b2dd03c8-39d4-4d8f-98ff-823fe69b080e>
Date: Wed Jul 18 10:30:41 2018 +0000
reduce tzset

* time.c (rb_localtime_r): call tzset() only after TZ environment
variable is changed.

 

 

ところで、さきほどの localtime_r の man を注意深く読まれた読者は、あれ? localtime_r も tzset()を内部的に呼びだすっぽいこと書いてあるけど?と思うかもしれません。賢いですね。

 

関数 localtime() は、カレンダー時刻 timep を ユーザーが指定したタイムゾーンでの時刻要素別の表現へ変換する。 この関数は tzset(3) を呼び出したかのように振舞い、 大域変数 tzname に現在のタイムゾーンの情報を設定する。 また、timezone に協定世界時 (UTC) とローカル標準時との 時差の秒数を設定し、 一年の一部で夏時間が適用される場合は daylight に 0 が設定される。 返り値は静的に確保された構造体を指しており、この後で 日付や時刻に関する関数のいずれかが呼び出されると 上書きされる可能性がある。 localtime_r() も同様だが、 データはユーザーが用意した構造体に格納される。 この関数は tzname, timezone, and daylight を設定する必要はない。

 

実は glibc は localtime() は毎回 tzset() を呼び出すが、localtime_r() は最初の1回だけtzset() を呼び出すという最適化をしています。

 

具体的には glibcのソースコード中の time/tzset.c の以下のあたりで切り分けています。

tzset_internal()の引数が0になると最初の一回のみ初期化という意味、tp == &_tmbuf はlocaltime()のとき1、 use_localtime はlocaltime(),localtime_r()どちらでも1です(gmtimeなどのときに0になります)

 

/* Return the `struct tm' representation of TIMER in the local timezone.                                                                                     Use local time if USE_LOCALTIME is nonzero, UTC otherwise.  */struct tm *
__tz_convert (__time64_t timer, int use_localtime, struct tm *tp)
{longint leap_correction;int leap_extra_secs;

  __libc_lock_lock (tzset_lock);

  /* Update internal database according to current TZ setting.                                                                                                 POSIX.1 8.3.7.2 says that localtime_r is not required to set tzname.                                                                                      This is a good idea since this allows at least a bit more parallelism.  */
  tzset_internal (tp == &_tmbuf && use_localtime);

 

コメントに理由が書いてありますね。つまり、tzset() で毎回 /etc/localtimeをstatするのはタイムゾーン処理のためで、localtime_r()はタイムゾーンまわりの処理が不要だから、ということのようです。

 

 mktime問題

残った mktime 問題ですが、grepすると呼び出し箇所は簡単に見つかります。 

 

time.c 

staticconstchar *
find_time_t(struct tm *tptr, int utc_p, time_t *tp)
{
(snip)#if defined(HAVE_MKTIME)
    tm0 = *tptr;if (!utc_p && (guess = mktime(&tm0)) != -1) {
        tm = GUESS(&guess);if (tm && tmcmp(tptr, tm) == 0) {goto found;
        }
    }#endif

 

これはラッキーです。mktime() があれば使うが、なければないで特に困らないという実装になっています。このような実装になっている理由については 

 

APIデザインケーススタディ ~Rubyの実例から学ぶ。問題に即したデザインと普遍の考え方』 

 

という本に解説があるので興味がある人は一読するといいと思います。このケースに限らずRubyのいろいろなAPIデザインの知見がつまったとてもいい本です。

 

要約すると mktime() があれば使うが、なかったり信用できなかったりした場合は、64bit integerの全空間を二分探索でlocaltime_r() を使って力業で探索するということです。

 

 ふつうに考えれば、前者のほうが明らかに速いのですが、今回我々は、mktimeは毎回tzset() を呼ぶが、localtime_r () は最初の一回のみ tzset() を呼ぶ。そして tzset() はとても遅い、ということを学びました。

つまり、毎回力業探索をしたほうが速いと言うことは十分ありえるとことです。やってみましょう!

 

 結果測定

t = Time.now; 100000.times { Time.new(2019) }; Time.now - t

 

のケースで
Ruby 2.6:                                          0.387sec
Ruby 2.6 + TZ環境変数:                   0.197sec
Ruby 2.7開発ツリー:                        0.162sec
Ruby 2.7開発ツリー+TZ環境変数:   0.165sec

 

と約2倍の高速化を達成し、かつTZ環境変数ありなしで速度が同等になりました。

 

 さらに検討をすすめる

 

じゃあ、削ろうかなと準備していたところに、オリジナル作者のakrさんから

 

mktime が tzset する副作用は意図して使っていたわけじゃないけど、ユーザから見ると /etc/localtime の変化に自動的に追従する挙動を提供していたと思う。
追従しない挙動に変えるのなら、代替手段が存在した方が説明しやすいと思う。
誰も気にしない可能性もあるけど。

 

というコメントをいただきました。その後しばらくコミッタ同士で議論して、

ENV['TZ'] = nil

したら、次のTime.newはtzsetを呼びなおすという挙動にすることにしました。これはRuby 2.6でのlocaltime_rの高速化をした

 

commit 4bc16691279e98ecdb3e19ff23902be671d46307Author: nobu <nobu@b2dd03c8-39d4-4d8f-98ff-823fe69b080e>
Date: Wed Jul 18 10:30:41 2018 +0000
reduce tzset

* time.c (rb_localtime_r): call tzset() only after TZ environment
variable is changed.

 

において、TZ環境変数を設定したらtzset()を呼びなおすという実装がすでに入っていたため、それを素直に拡張して、ENV['TZ'] = nil のときは元々nilだったとしてtzset()をしなおす(/etc/localtimeが変更されていればtimezoneは変わる)という挙動にしました。

  

  

まとめ

今回議論した mktime削除パッチは

https://github.com/ruby/ruby/commit/4d7a6d04b2c71aabb9d6e619f4405887806a5be8

としてコミット済みのため、とくに問題なければRuby 2.7に含まれるはずです。

 

よって要約すると

  • Ruby 2.6ではTime.newが3倍ぐらい速くなった
  • Ruby 2.7ではさらに2倍ぐらい速くなる(予定)
  • そのため、2.7以降では、TZ環境変数を設定する必要はまったくない

 ということになります。

 

アカツキさまのおかげで全世界のRailsアプリが高速化されました。このような改善につながるバグ報告は大変ありがたく、とても素晴らしい貢献だと思います。

 

Redash上からBigQueryスクリプトを利用してみた

$
0
0

この記事は Akatsuki Advent Calendar 2019 - Adventar 4日目の記事です。

こんにちは! suwahime です。

昨今どの業界を見渡しても、アクティブユーザー数や入会数といったデータは、当たり前のように日々追っているかと思います。 私の所属するチームでは、主にRedashを用いてKPIを可視化しています。

今日は、先月BigQueryにBetaリリースされたスクリプトとプロージャ機能を使って、RedashのQuery作成を、よりシンプルに、管理しやすい形で書いてみたことをお話しさせていただきます。

RedashはQueryごとに定義が分散してしまいがち

例として、「期間内にサービスを訪れたユーザー」の利用デバイスを調べる、以下のようなQueryをRedashに作成していたとします。

SELECT B.device_name, COUNT(DISTINCT A.user_id) FROM
  (SELECT * FROM dataset.login_user_ids
    WHERE timestamp >= TIMESTAMP("{{start_time}}")
    AND timestamp <= TIMESTAMP("{{end_time}}")
    GROUPBY user_id) AS A
  LEFTJOIN
  (SELECT user_id, device_name FROM dataset.user_devices) AS B
  ON A.user_id = B.user_id
GROUPBY B.device_name

テーブルの内容等は割愛しますが、利用者はstart_timeとend_timeをRedash上のGUIから入力することで、数字が見られるというイメージです。

Redashはこのように愚直なSQLを書いても簡単にグラフ化までできる魔法のようなツールなのですが、いかんせん簡単すぎて、考えなしに様々なQueryを作成してしまい、後々の定義変更が大変だったりすることがあります。

たとえば、以下のような要求がきた場合はどうなるでしょうか。

  • 期間ごとの不正なユーザーIDを検知することができたので、その一覧をKPIから削減したい。
  • ログインしたユーザーではなく、サービスをプレイしたユーザーから取るように変えてほしい。

中間テーブルを作成するなど、やり方はいろいろあると思いますが、ここではまた愚直に以下のように修正してみます。

SELECT B.device_name, COUNT(DISTINCT A.user_id) FROM
(SELECT * FROM dataset.play_user_ids -- 参照するテーブルを置き換えWHERE timestamp >= TIMESTAMP("{{start_time}}")
  AND timestamp <= TIMESTAMP("{{end_time}}")
  AND user_id NOTIN-- 不正ユーザーIDを弾く
  (SELECT user_id FROM dataset.wrong_user_ids
    WHERE timestamp >= TIMESTAMP("{{start_time}}")
    AND timestamp <= TIMESTAMP("{{end_time}}"))
  GROUPBY user_id) AS A
LEFTJOIN
(SELECT user_id, device_name FROM dataset.user_devices) AS B
ON A.user_id = B.user_id
GROUPBY B.device_name

Redashの埋め込み記法である {{}} が増えて少々見づらくなりましたが、なんとか簡単にできました。Redashはこのフットワークの軽さが良いですね。

では、更に次のような要求が来た場合はどうでしょう?

  • 「期間内にサービスを訪れたユーザー」を元に調査している他の全てのQueryについても、同様に置き換えて欲しい。

…これは少々困りました。Query数が少ないプロジェクトなどは、そんなにコストがかからず置き換え可能なのかもしれません。しかし、誰でも好き勝手にQuery作成が可能なプロジェクトにおいては、どこでこの指標を利用しているのか、検索して見ていくしかありません。

もし「期間内にサービスを訪れたユーザー」の定義が一元管理できていたら、こんな手間は無くなると思いませんか?

RedashとBigQueryスクリプトを組み合わせて使ってみる

ではここで、BigQueryスクリプトを使った解決策を試してみましょう。

まずは「期間内にサービスを訪れたユーザー」をaccess_user_idsというTEMP TABLEに吐き出すプロシージャを、以下のスクリプトをBigQuery上で実行することで登録してみます。

CREATEPROCEDURE dataset.create_access_user_ids (start_date TIMESTAMP, end_date TIMESTAMP)
BEGINCREATE TEMP TABLE access_user_ids ASSELECT * FROM dataset.play_user_ids
    WHERE timestamp >= start_date
    AND timestamp <= end_date
    AND user_id NOTIN
    (SELECT user_id FROM dataset.wrong_user_ids
      WHERE timestamp >= start_date
      AND timestamp <= end_date)
    GROUPBY user_id;
END;

これをCALL関数で呼び出すことで、RedashのQueryは以下のように書けるようになります。

CALL dataset.create_access_user_ids(TIMESTAMP("{{start_time}}"), TIMESTAMP("{{end_time}}"));

SELECT B.device_name, COUNT(DISTINCT access_user_ids.user_id) FROM
access_user_ids
LEFTJOIN
(SELECT user_id, device_name FROM dataset.user_devices) AS B
ON access_user_ids.user_id = B.user_id
GROUPBY B.device_name;

見た目がだいぶスッキリしましたね。しかも、今後定義変更があった場合にはプロシージャ側を編集するだけで、access_user_idsを使っているすべてのQueryが同じ指標に置き換わります。

注意すべきは、1クエリではなくスクリプトになるため、区切り文字「;」が必要になることと、プロシージャ内で作られるTEMP TABLEの名前がRedashからだと隠蔽されてしまうため、命名規則などで対応する必要があることです。

RedashからBigQueryスクリプトを使うことのメリット、デメリット 

プロシージャを利用することの最大のメリットは、RedashのQueryごとに指標がバラけることなく一元管理することができることです。他にも、BigQueryからRedashにSQLをコピペしてから、時間範囲の部分だけを {{start_time}} と {{end_date}} で置き換えて…なんていう手間を省くこともできます。最終的にはプロシージャや中間テーブルだけでクエリを作成して、RedashからはCALLするだけという運用にすれば、ダッシュボードツールに縛られることのない未来もありそうです。

デメリットは、BigQueryスクリプト内で書かれたSQLに関して、処理される推定バイト数が実行前にわからないことです。想定外に検索費用がかかってしまうこともあるかもしれません。これに関しては、たとえば先にdry runを実行して処理する推定バイト数を算出し、一定以上の費用がかかりそうなら実行しない、ということができるようになればありがたいですね。(現時点では、コンソール上からdry runをすることはできないようです。)

BigQueryスクリプトで可能になることはまだまだありそうなので、今後も追ってみたいと思います。

参考

cloud.google.com

cloud.google.com

cloud.google.com

UniTaskを使った話

$
0
0

この記事は Akatsuki Advent Calendar 2019 - Adventar 5日目の記事です。

 

はじめに

クライアントエンジニアのkawawa1989です。

現在はUnityを使っての開発を行っています、今回は初めて導入したUniTaskについてご紹介をしたいと思います。

 

UniTaskとは🤔🤔🤔

Cysharpさんが開発された、Unityでメインスレッドを使ってasync/awaitを使えるようにするプラグインです。
ちなみにUniRxは事前に入れる必要があるとか、そういうことはありませんでした。
UniRxなしでも単体で扱うことができます。
tech.cygames.co.jp




今まではIEnumeratorを使って書いていた箇所をasync/awaitで置き換えることができるようになります。

 

やりたいこと🤔

通信などの非同期処理などが多い場合、「通信が成功した(or失敗した)」のコールバック関数だらけになってしまいます。
例えばログインのフローでこんなコードがあったとします。

// ログインフロー// 例えばログインボタンがタップされてログインするとする場合void OnLoginButtonClick()
    {
        // 利用規約が読まれていないなら利用規約UIを出したいif (!HasReadTermOfUse)
        {
            OpenTermOfUseDialog(onAgreed: OnAgreedEvent);
            return;
        }
        // ログインAPIを叩く
        NetworkManager.Login(onLoginSuccess: OnLoginSuccessEvent);
    }

    // 利用規約が同意されたvoid OnAgreedEvent()
    {
        // ログインAPIを叩く
        NetworkManager.Login(onLoginSuccess: OnLoginSuccessEvent);
    }


    // ログイン完了void OnLoginSuccessEvent()
    {
        LoadNextScene();
    }

 ぶっちゃけこのレベルなら個人的には許容できるのですが、私が実際に経験したコードだとログイン一つでこれを遥かに超える複雑な作りになっていました。
なのですが、あまり具体的なことは社外秘のためとりあえずこの範囲で...
上記のような処理を、できるなら一つの関数の中で何をやっているのかすべて理解できるようにしたいですね。
 

まず最初に考えたやり方🤔

最初はコルーチンでやってしまおうと思いました。

    IEnumerator LoginFlow()
    {
        if (!HasReadTermOfUse)
        {
            var dialog = OpenTermOfUseDialog();
            // ダイアログを表示している間ずっと待機し続けるyieldreturn dialog;

            // 終了したら承認されたかどうかの判定を行う// 利用規約に同意してもらえなかったらそこで終了if (!dialog.Agreed)
            {
                yieldbreak;
            }
        }

        // ログインAPIを叩く
        var login = NetworkManager.Login();
        yieldreturn login;

        // ログインに成功したのかどうか// 失敗したなら終了if (!login.Success)
        {
            yieldbreak;
        }

        LoadNextScene();
    }

これで一つの関数内に収めることができます
が、いちいちAsyncOperation的なやつを取らないといけないのが面倒くさいです。
UniRxを使えばFromCoroutineでどうにかなるものの、StartCoroutineを呼び出す元になるスクリプトがないと動かせないのも欠点です。

みたいなことを会社の同僚と話していて「そういう事やりたいならUniTaskってのがあるから、それ使ってみては?」
という案が出ました。

というわけで早速導入してみました。
参考になった記事
UniRx.Async(UniTask)機能紹介 - Qiita

当初、Incremental Compilerなるものが必要で、
しかもそれはPreview版とのことだったので若干UniTaskは避けていたのですが、どうやらそれは昔の話で最近は違うらしいと知りました。

 

Incremental Compilerが不要に!😇

neue cc - UniTask(UniRx.Async)から見るasync/awaitの未来
ということでこれで安心して導入できます!!

    async UniTask LoginFlow()
    {
        // ダイアログを表示して結果が返ってくるまで待機する。if (!HasReadTermOfUse)
        {
            var agreed = await OpenTermOfUseDialog();
            if (!agreed)
            {
                return;
            }
        }

        // ログインAPIを叩く
        var success = await NetworkManager.Login();
        // エラーだったら終了if (!success)
        {
            return;
        }
        LoadNextScene();
    }

よりスッキリしました!
戻り値をawaitで待機しながら受け取れるのは便利ですね!

とはいえ全部をいきなりUniTaskに置き換えるのは難しくない?

私がUniTaskを導入したのはプロジェクトのかなり終盤でした。
そのため、全てをUniTaskに置き換えることはできないのですが、それでもやりようはあります。
例えばダイアログ関係はこのように書き換えることができます。

// 元のコードpublicvoid ShowDialog(string title, string message, Action onClose)
    {
        var prefab = Resources.Load("HogeHogeDialog");
        var dialog = Instantiate(prefab);
        dialog.Initialize(title, message, onClose);
    }

    // これを元に改造public UniTask ShowDialogAsync(string title, string message)
    {
        var source = new UniTaskCompletionSource();
        var prefab = Resources.Load("HogeHogeDialog");
        var dialog = Instantiate(prefab);
        dialog.Initialize(title, message, () =>
        {
            source.TrySetResult();
        });
        return source.Task;
    }

UniTaskCompletionSource

UniTaskCompletionSourceというやつで終了時にTrySetResultすれば元の実装をいじくる必要なくUniTaskで待てるように改造できます!
これも最初は自分はまったく知らなかった機能で、知る前までは毎回

        var isDone = false;
        dialog.Initialize(title, message, () =>
        {
            isDone = true;
        });
        await UniTask.WaitUntil(() => isDone);

こんな風に書いてました。

UniTaskの中身を解析してみよう🤔

どう動いているのか非常に気になったので、中身を解析してみました。

PlayerLoopHelper

コルーチンの場合、StartCoroutineを走らせるためのMonoBehaviourがどうしても必要でした。
でしたが、PlayerLoopを上書きできるようになったので、この仕組みで直接UniTaskを走らせる用のUpdate関数を登録しているようでした。
PlayerLoopに関しての参考リンク
tsubakit1.hateblo.jp

PlayerLoopHelper.csの中身

publicstaticclass PlayerLoopHelper
    {
        publicstatic SynchronizationContext UnitySynchronizationContext => unitySynchronizationContetext;
        publicstaticint MainThreadId => mainThreadId;

        staticint mainThreadId;
        static SynchronizationContext unitySynchronizationContetext;
        static ContinuationQueue[] yielders;
        static PlayerLoopRunner[] runners;


yieldersrunnersがどうにも気になります。
PlayerLoopHelperのInitというメソッドがRuntimeInitializeOnLoadMethodによって起動時に呼び出されているようです。

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
        staticvoid Init()
        {
            // capture default(unity) sync-context.
            unitySynchronizationContetext = SynchronizationContext.Current;


Initialize内では以下のような感じになっていました。
InsertRunnerでループ処理を追加しています。

publicstaticvoid Initialize(ref PlayerLoopSystem playerLoop)
        {
            yielders = new ContinuationQueue[7];
            runners = new PlayerLoopRunner[7];

            var copyList = playerLoop.subSystemList.ToArray();

            copyList[0].subSystemList = InsertRunner(copyList[0], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldInitialization), yielders[0] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerInitialization), runners[0] = new PlayerLoopRunner());
            copyList[1].subSystemList = InsertRunner(copyList[1], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldEarlyUpdate), yielders[1] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerEarlyUpdate), runners[1] = new PlayerLoopRunner());
            copyList[2].subSystemList = InsertRunner(copyList[2], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldFixedUpdate), yielders[2] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerFixedUpdate), runners[2] = new PlayerLoopRunner());
            copyList[3].subSystemList = InsertRunner(copyList[3], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPreUpdate), yielders[3] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPreUpdate), runners[3] = new PlayerLoopRunner());
            copyList[4].subSystemList = InsertRunner(copyList[4], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldUpdate), yielders[4] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerUpdate), runners[4] = new PlayerLoopRunner());
            copyList[5].subSystemList = InsertRunner(copyList[5], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPreLateUpdate), yielders[5] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPreLateUpdate), runners[5] = new PlayerLoopRunner());
            copyList[6].subSystemList = InsertRunner(copyList[6], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPostLateUpdate), yielders[6] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPostLateUpdate), runners[6] = new PlayerLoopRunner());

 
InsertRunnerの中身を見たところ、yieldLoopというのとrunnerLoopというループがあるのですが、
これをUnityの各PlayerLoopに2つずつ挿入しています。
 
f:id:kawawa1989:20191205112536j:plain


このループの中で実際にやっている処理はContinuationQueuePlayerLoopRunnerというやつをそれぞれ走らせています。
yieldLoop内でContinuationQueueのRunを走らせて
runnerLoop内でPlayerLoopRunnerのRunを走らせています。
では次にContinuationQueuePlayerLoopRunnerを見てみます。

ContinuationQueue

内部的にはactionListというActionの配列を管理しているようです。
そしてこれをRun実行時に呼び出している。
Enqueueというメソッドが用意されていて、これを呼び出すことでActionが登録できそうです。
ではこのEnqueueはどこから呼び出されているのだろう?

と思って追ってみたら、PlayerLoopHelperで管理しているようでした。
イメージとしては以下の図のような感じでしょうか。

f:id:kawawa1989:20191205113005j:plain
では、次にこのAddActionとAddContinautionがどこから呼び出されているか調べてみます。

f:id:kawawa1989:20191205114020p:plain
ReusablePromise.csが気になるので見てみます。
PlayerLoopReusablePromiseBaseというクラスのIsCompletedというメソッドで呼び出されていました。

PlayerLoopReusablePromiseBase

publicoverridebool IsCompleted
        {
            get
            {
                if (Status == AwaiterStatus.Canceled || Status == AwaiterStatus.Faulted) returntrue;

                if (!isRunning)
                {
                    isRunning = true;
                    ResetStatus(false);
                    OnRunningStart();
#if UNITY_EDITOR
                    TaskTracker.TrackActiveTask(this, capturedStackTraceForDebugging);
#endif
                    PlayerLoopHelper.AddAction(timing, this);
                }
                returnfalse;
            }
        }


PlayerLoopReusablePromiseBaseの実装を見ればわかりますが、IPlayerLoopというやつを実装していました。

publicabstractclass PlayerLoopReusablePromiseBase : ReusablePromise, IPlayerLoopItem
    {

これはMoveNextというメソッドを提供するインターフェースのようで、
MoveNextメソッドはPlayerLoopReusablePromiseBaseを継承した先で実装されていました。
WaitUntilPromise
WaitWhilePromise
YieldPromise

PlayerLoopRunnerは基本的にこのIPlayerLoopItemを配列で管理して、毎ループ時に各IPlayerLoopItemのMoveNextを呼び出しているようです。
次はUniTaskを見ます。
AddActionメソッドにログを仕込んでみました。
で、下記のようなサンプルコードを書いてみました。とりあえず3秒間待つタスクです。

bool isDone = false;
    privatevoid Awake()
    {
        Run().Forget();
        StartCoroutine(WaitCoroutine());
    }

    private IEnumerator WaitCoroutine()
    {
        yieldreturnnew WaitForSeconds(3.0f);
        isDone = true;
    }

    private async UniTask Run()
    {
        Debug.Log("Run Start!!");
        await UniTask.WaitUntil(() => isDone);
        Debug.Log("Run End!!");
    }

f:id:kawawa1989:20191205115942p:plain
呼ばれています!
呼び出し先を見てみると、PlayerLoopReusablePromiseBaseIsCompletedというプロパティで呼び出されているのがわかります。
PlayerLoopReusablePromiseBaseIsCompletedを呼び出しているのはUniTaskIsCompletedプロパティのようです。

UniTask

IsCompleted

UniTask.IsCompleted

        [DebuggerHidden]
        publicbool IsCompleted
        {
            get
            {
                return awaiter == null ? true : awaiter.IsCompleted;
            }
        }

UniTaskawaiterというフィールドを持っていることがわかります。
このawaiterにPlayerLoopReusablePromiseBaseのインスタンスを設定しているということになります
そして、これを呼び出しているのはAwaiterというやつのようですAwaiterはUniTask内に定義されています。
Awaiter

Awaiter
publicstruct Awaiter : IAwaiter
        {
            readonly UniTask task;
            [DebuggerHidden]
            public Awaiter(UniTask task)
            {
                this.task = task;
            }

こいつの中にUniTaskが内包されているようです。
で、実際はAwaiterのIsCompletedからUniTaskのプロパティにアクセスしているようです。

IAwaiter

IAwaiterの定義自体はこんな感じです。

publicinterface IAwaiter : ICriticalNotifyCompletion
    {
        AwaiterStatus Status { get; }
        bool IsCompleted { get; }
        void GetResult();
    }
ICriticalNotifyCompletion

ICriticalNotifyCompletionはちょっとよくわからないので調べます。
どうもTaskでも使用されているインターフェースのようです。

.NET Framework標準で提供されているTaskAwaiter/TaskAwaiter型はINotifyCompletionインターフェースを継承するICriticalNotifyCompletionインターフェースを実装しています。

参考記事
このIsCompletedはどこで呼ばれているのか?
IsCompleted内部にログを仕込んでみます。
f:id:kawawa1989:20191205121706p:plain

どうやらAsyncUniTaskMethodBuilderとかいうやつが居るようで、そいつが呼び出しているようです。
では今度はこいつのStartメソッドに同じようなログを仕込んでみましょう。
f:id:kawawa1989:20191205121755p:plain

これ以上先はもう何もありません。
このAsyncUniTaskMethodBuilderが何者なのか?といろいろ調べていたら、これはどうもコンパイラ側が使用するクラスのようです
www.atmarkit.co.jp

IAsyncStateMachineというやつが何か関連がありそうです。
コイツについて調べてみましょう。
docs.microsoft.com

非同期メソッドに生成されるステート マシンを表します。 この型はコンパイラでのみ使用されます。

他にもこのような記事がありました。
blog.xin9le.net

・async/awaitは何かの糖衣構文で、実際はコンパイラがもっと複雑な形に変換している
・exeやdllには変換後の形で格納されている

とのことなので、試しにILSpyで中身を開いてみます。

    .method /* 06000006 */private hidebysig 
        instance valuetype [UniRx.Async]UniRx.Async.UniTask Run () cil managed 
    {
        .custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
            01001254657374436f6465 2b 3c 5275 6e
            3e 645f5f360000
        )
        .custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = (
            01000000
        )
        // Method begins at RVA 0x2080// Code size 59 (0x3b)
        .maxstack 2
        .locals /* 11000001 */ init (
            [0] class TestCode/'<Run>d__6',
            [1] valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder
        )

        IL_0000: newobj instance void TestCode/'<Run>d__6'::.ctor() /* 06000012 */IL_0005: stloc.0IL_0006: ldloc.0IL_0007: ldarg.0IL_0008: stfld class TestCode TestCode/'<Run>d__6'::'<>4__this'/* 04000008 */IL_000d: ldloc.0IL_000e: call valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::Create() /* 0A000012 */IL_0013: stfld valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder'/* 04000007 */IL_0018: ldloc.0IL_0019: ldc.i4.m1
        IL_001a: stfld int32 TestCode/'<Run>d__6'::'<>1__state'/* 04000006 */IL_001f: ldloc.0IL_0020: ldfld valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder'/* 04000007 */IL_0025: stloc.1IL_0026: ldloca.s 1IL_0028: ldloca.s 0IL_002a: call instance void [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::Start<class TestCode/'<Run>d__6'>(!!0&) /* 2B000001 */IL_002f: ldloc.0IL_0030: ldflda valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder'/* 04000007 */IL_0035: call instance valuetype [UniRx.Async]UniRx.Async.UniTask [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::get_Task() /* 0A000014 */IL_003a: ret
    } // end of method TestCode::Run

AsyncUniTaskMethodBuilder::Create()とかAsyncUniTaskMethodBuilder::Start()とか呼ばれてます!
ILコードがよくわかりませんが、callとか呼ばれてますね。
(この辺もそのうち勉強してみたい)

[参考リンク]
www5b.biglobe.ne.jp

次に気になったこと

どうやってAsyncUniTaskMethodBuilderを呼び出している?🤔

これがちょっと謎でしたが、UniTask.csの定義をよく見るとAsyncMethodBuilderというやつがあります!

    [AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))] // ←これ!!!!publicpartialstruct UniTask : IEquatable<UniTask>

AsyncMethodBuilderに関して参考リンク
qiita.com

つまりawaitが呼ばれたとき、その戻り値の型に対応されたAsyncMethodBuilderが呼び出される
ということでしょうか?
ここ、調べてもあまり出てこないのでちょっとわからない。
おそらくこんな感じ

f:id:kawawa1989:20191205122359j:plain
試しに
UnsafeOnCompleted(Action act)
で受け取るデリゲートを最後に呼び出さないようにしてみたら
永遠にawaitから戻ってこれない状態になったので、おそらくこれは処理が終了したあとに呼び出さないといけないやつ。
GetAwaiterが何者なのか気になったけど、これは戻り値の型が独自定義型であろうとなんだろうと関係なく呼ばれるように見えます。
docs.microsoft.com
ちなみにGetAwaiterをコメントアウトするとawaitする箇所全てでコンパイルエラーになって死にます。

要約

全体のライフサイクルはこんな感じ?だと思われる🤔
f:id:kawawa1989:20191205122539j:plain


オレオレTaskを作ってみよう!

試しに自分でawaitできるタスクを作ってみます。
UniTaskはstructで定義されていましたが、structだとデフォルトコンストラクタが定義できないので今回はclassで定義しています。

using System.Runtime.CompilerServices;
using System;

namespace Bz.Brotherhood
{
    [AsyncMethodBuilder(typeof(BzTaskBuilder))]
    publicclass BzTask
    {
        IAwaiter awaiter;

        public BzTask()
        {
            UnityEngine.Debug.Log($"BzTask.Constructor");
        }

        public BzTask(IAwaiter awaiter)
        {
            this.awaiter = awaiter;
        }

        public IAwaiter GetAwaiter()
        {
            UnityEngine.Debug.Log($"BzTask.GetAwaiter");
            returnnew Awaiter(awaiter);
        }

        privatestruct Awaiter : IAwaiter
        {
            IAwaiter awaiter;
            public Awaiter(IAwaiter awaiter)
            {
                this.awaiter = awaiter;
            }

            publicbool IsCompleted
            {
                get
                {
                    var isCompleted = true;
                    if (awaiter != null)
                    {
                        isCompleted = awaiter.IsCompleted;
                    }
                    UnityEngine.Debug.Log($"Awaiter.IsCompleted :{isCompleted}");
                    return isCompleted;
                }
            }

            publicvoid OnCompleted(Action moveNext)
            {
                UnityEngine.Debug.Log("Awaiter OnCompleted");
                awaiter?.OnCompleted(moveNext);
            }

            publicvoid UnsafeOnCompleted(Action moveNext)
            {
                UnityEngine.Debug.Log("Awaiter UnsafeOnCompleted");
                awaiter?.UnsafeOnCompleted(moveNext);
            }

            publicvoid GetResult()
            {
                UnityEngine.Debug.Log("Awaiter GetResult");
                awaiter?.GetResult();
            }
        }
    }

AsyncMethodBuilderAttributeは存在しない?

UniTaskを真似してオレオレTaskを作ってみようと思いたち、似たようなコードを書いてみたのですがエラーが出ます。
f:id:kawawa1989:20191205122824p:plain
UniTaskはAsyncMethodBuilderAttributeを定義しているようでした。
blog.meilcli.net

また、現時点でほとんどのプラットフォームではAsyncMethodBuilderAttribute属性も自分で定義する必要があります。

理由はよくわからないのですが、とにかくこっちで定義しないと駄目らしい。
以下のようなオレオレタスクビルダーを作ってみました(といってもほぼUniTaskBuilderのコピペ)

namespace Bz.Brotherhood
{
    publicstruct BzTaskBuilder
    {
        IAwaiter awaiter;

        // 1. Static Create method.publicstatic BzTaskBuilder Create()
        {
            UnityEngine.Debug.Log("BzTaskBuilder.Create");
            var builder = new BzTaskBuilder();
            return builder;
        }

        // 2. TaskLike Task property.public BzTask Task
        {
            get
            {
                UnityEngine.Debug.Log("BzTaskBuilder.Task");
                returnnew BzTask(awaiter);
            }
        }

        // 3. SetExceptionpublicvoid SetException(Exception exception)
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetException");
        }

        // 4. SetResultpublicvoid SetResult()
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetResult");
        }

        // 5. AwaitOnCompletedpublicvoid AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : INotifyCompletion
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.AwaitOnCompleted");
            this.awaiter = awaiter as IAwaiter;
            this.awaiter.OnCompleted(stateMachine.MoveNext);
        }

        // 6. AwaitUnsafeOnCompleted
        [SecuritySafeCritical]
        publicvoid AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : ICriticalNotifyCompletion
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.AwaitUnsafeOnCompleted");
            this.awaiter = awaiter as IAwaiter;
            this.awaiter.UnsafeOnCompleted(stateMachine.MoveNext);
        }

        // 7. Startpublicvoid Start<TStateMachine>(ref TStateMachine stateMachine)
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.Start");
            stateMachine.MoveNext();
        }

        // 8. SetStateMachinepublicvoid SetStateMachine(IAsyncStateMachine stateMachine)
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetStateMachine");
        }
    }
}


そして最後にこんなテストコードを試しに作ってみます。

void Start()
    {
        Debug.Log("Start");
        TestTask();
    }

    async BzTask HelloWorldTask()
    {
        Debug.Log("Hello World!!");
    }

    async BzTask TestTask()
    {
        Debug.Log("TestTask Start");
        await HelloWorldTask();
        Debug.Log("TestTask End");
    }

これでも一応エラーになりません(まったく待機とかしないけど)
そして実行してみます。
f:id:kawawa1989:20191205123138p:plain

動いた!
現在のままだとawaitとか言っておきながら全く待機していないので今度は例えば100フレーム後に処理が進行するようにしてみたいです。
このBzTaskのIAwaiterに何か実装を渡せばその実装に合わせて動くようになっています。
IAwaiterの中身はUniTaskのものとほぼ同じです。

publicinterface IAwaiter : ICriticalNotifyCompletion
    {
        // BzTask.Awaiter内部で呼び出される。bool IsCompleted { get; }
        void GetResult();
    }


そしてこのIAwaiterを実装した100フレーム待機するクラスを作ってみます。

privateinterface IOnUpdate
    {
        bool OnUpdate();
    }

    privateclass FrameAwater : IAwaiter, IOnUpdate
    {
        int frame = 0;
        int waitFrameCount = 0;
        Action moveNext = null;

        public FrameAwater(int waitFrameCount)
        {
            this.waitFrameCount = waitFrameCount;
        }

        publicbool OnUpdate()
        {
            Debug.Log($"OnUpdate[frame:{frame}, waitFrameCount:{waitFrameCount}]");
            if (frame == waitFrameCount)
            {
                moveNext();
                returnfalse;
            }
            frame += 1;
            returntrue;
        }

        // BzTask.Awaiter内部で呼び出される。publicbool IsCompleted { get { returnfalse; } }
        publicvoid GetResult()
        {
        }

        publicvoid OnCompleted(Action continuation)
        {
            moveNext = continuation;
        }

        publicvoid UnsafeOnCompleted(Action continuation)
        {
            moveNext = continuation;
        }
    }


そしてMonoBehaviour側にIOnUpdateをUpdate時にコールする、みたいなやつを追加します。

    List<IOnUpdate> list = new List<IOnUpdate>();
    List<IOnUpdate> removeList = new List<IOnUpdate>();
    void Update()
    {
        removeList.Clear();
        foreach (var update in list)
        {
            if (!update.OnUpdate())
            {
                removeList.Add(update);
            }
        }
        foreach (var item in removeList)
        {
            list.Remove(item);
        }
    }

そして試しに60フレーム待機するように以下のように書き換えてみます。

void Start()
    {
        Debug.Log("Start");
        TestTask();
    }

    BzTask WaitForFrame(int frame)
    {
        Debug.Log("WaitForFrame");
        var frameAwaiter = new FrameAwater(frame);
        list.Add(frameAwaiter);
        returnnew BzTask(frameAwaiter);
    }

    async BzTask TestTask()
    {
        Debug.Log("TestTask Start");
        await WaitForFrame(60);
        Debug.Log("TestTask End");
    }

意図した通りに動くのであれば、
60フレーム経過したらUnsafeOnCompleted()で受け取ったデリゲートを実行し、それによってタスクは終了するはずです。

f:id:kawawa1989:20191205123431p:plain
f:id:kawawa1989:20191205123516p:plain
しっかり60フレーム待機してくれています!
長くなりましたがたまにこうやって他の人の作ったプラグインのソースコードを除くのは楽しいですね。


ここまで読んでくださってありがとうございました!

アカツキでのインターン戦記:クライアントエンジニア編

$
0
0

f:id:kenji-hanada:20190925180518p:plain少し前の話になりますが、9/5 ~ 9/20 まで アカツキでインターンをしてきたうじまるです。 今回はその体験記を書いていこうと思います。アカツキのインターンにこれから参加する/検討している人の参考になれば嬉しいです。

目次

インターンの内容

八月のシンデレラナインのクライアント側の開発をしてました。 主にやっていたことは

  ・既存の実装だと動作が重くなっている部分を改善する

  ・累積報酬を試合結果画面からみえるようにする

  ・SR選手専用のクリスタルベアマックスの背景画像をクライアント側で生成する

ということをやってました。大きめのタスクがあってそれを2週間でやるという形ではなく小さめのタスクをたくさんやるという感じでやっていました。小さめのタスクだったので進捗が出てる感があってよかったです。

これにプラスして 9/14 ~ 9/15 にあった AKATSUKI GAME JAM も参加しました。

既存の実装だと動作が重くなっている部分を改善する

既存の実装だとアイテム一覧や練習参加先一覧がデータの数だけ生成されてしまいその数が大きいと重くなっていました。そこでそれを軽くするために表示に必要な数だけオブジェクトを生成するように修正するようにしました。必要分生成するComponentはすでにあったのでそれを使うように実装するタスクでした。

累積報酬を試合結果画面からみえるようにする

累積報酬の詳細が試合結果画面から見れなかったのでそれを見れるようにしました。 現状だと試合を選択する画面からしか累積報酬の詳細を見ることができなかったのですが再戦をして周回すると現在どのくらいポイントが溜まっていて次がどの報酬なのか見れないようになっていました。

f:id:kenji-hanada:20190925180512p:plain
ここから累積報酬を確認できるようにする

前のタスクの実装は一部のComponentに修正を入れるだけだったのですがこのタスクはSceneの処理の流れやデータの流れを理解しないと実装が難しかったのでコードを読んだり実際にどんな処理をしているのかを理解するのが大変でした。

SSR選手専用のクリスタルベアマックスの背景画像をクライアント側で生成する

今まで背景画像をデザイナーさんが作っていたのですが、運用コスト的にクライアント側で生成できるならしたほうが良いよね、ということでそれを実装しました。

f:id:kenji-hanada:20190925180507p:plain
この画面のUIの後ろ側の画像を作る

最初に自分が考えていた実装方針もあったのですが、そっちだと現在のコードをうまく使って実装できなかったので別の方針でやることにしました。画像の合成はオブジェクトを2つ作って合わせても良かったのですが、実装をシンプルにしたかったのと現在の処理もうまく使いたかったのでシェーダーで実装することにしました。

マスターデータから画像IDを取ってきたり、デバッグ用にアイテム付与をするツールを使ったりソシャゲを作ってる感があってよかったです。

ただ、キャラ画像の位置調整が手に入るデータだけではうまくできなかったのでデザイナーさんと話してマスクの範囲を小さくするという結果になりました。

このままリリースされるかどうかはわかりませんが、基本的な部分は実装できたと思います。

AKATSUKI GAME JAM

テーマが「カラフル」で3~4人一組になって2日間掛けてゲームを作るというインターンです。 僕のチームは3人チームでチームメンバ全員がカレー(特にインドカレー)がまぁまぁ好きという理由で「ナンカレー」というチーム名になりました。

作ったゲームは「縦スクロールでロケットを操作して宇宙人を集めるゲーム」を作りました(なんかよく分かりませんね、僕もうまく説明できないです)

f:id:kenji-hanada:20190925180504j:plain
開発風景

結果、準優勝をして「ハチナイカレー」を貰いました。ナンカレーがハチナイカレーを貰えたのでなんか良かったです。

f:id:kenji-hanada:20190925180459j:plain

今回作ったゲームはリリースまで頑張ろうということになったのでリリース目指して頑張ろうと思います。

まとめ

直前に参加していたインターンとの兼ね合いがあり、3週間の予定が約2週間のインターンになってしまいましたが、それなりに進捗を出すことができたと思います! Unityのコードをレビューしてもらう機会が今までほとんど無かったのでとても勉強になりました。 他にも、インターン参加前に「設計とか見たい」と思ってましたが、この点でも普通に実装や設計で勉強になる部分が多かったのでいい経験になりました。

ゲーム開発の現場もエンジニア以外にも検証の人やデザイナーさん、プランナーさんなどなど色んな職種の人がいて楽しかったです。ソシャゲ開発あるあるなのか分からないですが、インターン初日がメンテの日で、メンテ明けにみんな一斉にハチナイのスカウトを回していたのがちょっと面白かったです。

自分のUnity力を試せた機会だったし、アカツキという会社がどんな会社なのか知ることができたのでとても良いインターンでした!!


AWS と GCP (GAE) それぞれのリージョン間のレイテンシを計測してみた

$
0
0

この記事は Akatsuki Advent Calendar 2019の 9 日目として書きました。

はじめに

みなさん、マルチクラウドしてますか!? 株式会社アカツキでエンジニアをしている @sachaosです。

弊社では各ゲームプロダクトから利用される共通基盤をマイクロサービスとして構築しています。 この共通基盤は Google Cloud Platform (以降 GCP) の Google App Engine (以降 GAE) Standard Environment により運用されています。 現在、ゲームプロダクトでは主に Amazon Web Services (以降 AWS) を利用しているため、ゲームサーバは AWS と 共通基盤は GCP のマルチクラウド構成となっています。

アカツキでは海外向けに提供しているゲームもあります。 日本にゲームサーバがあれば日本のプレイヤーは快適にゲームをすることが出来ますが、 海外向けのゲームで日本にゲームサーバがあると、物理的に遠いのでどうしてもレイテンシが発生してしまいます。 なので、海外版を提供する際にはプレイヤーの体験を損なわないようにするため、ゲームサーバをなるべくプレイヤーの近くに構築しています。

また、共通基盤となるサービスは同じ理由でゲームサーバの近くにあるべきです。 しかし、共通基盤を AWS の各リージョンから最も近いところに複数個デプロイするというのは、運用コストの点から現実的ではありません。

なので、運用コストを抑えつつもプレイヤーの体験を損なわないようにするため、 共通基盤を用意する GCP のリージョンを少なくして、かつ 様々な AWS リージョンからもレイテンシが低く保てるような GCP のリージョンを探したいです。

そこで、AWS 上に構築されるゲームサーバから GCP (GAE) 上に構築されている共通基盤までのレイテンシはどの程度なのか、 AWS の各リージョンから GCP (GAE) の各リージョンまでのレイテンシを計測してみました。

結果

AWS の各リージョンから GCP の各リージョンへ 20 回リクエストを送り、その中央値をとったものをまとめたものが以下の表です。 単位は ms です。 100ms 未満のものを緑色に、300ms より大きいものを赤色で塗っています。

f:id:sachaos:20191209164034p:plain

docs.google.com

計測方法

レイテンシの計測対象として GAE SE の go111 runtime を利用し、単純に Hello Worldを出力する HTTP サーバを作成し、各リージョンに配置しています。 そしてレイテンシの計測をするものとして Lambda を利用しました。 こちらも各リージョンに配置し、パラメータとして GAE のリージョンを指定すればそこに対して、計測を開始するようなものになっています。 AWS Lambda は Go のランタイムを利用しており、Go 製の負荷テストライブラリを利用しました。

f:id:sachaos:20191209145937p:plain

考察

当たり前ですが、近いところはレイテンシが低いですw どんな AWS のリージョンからでも、71 ms 以下で返せる GAE のリージョンがありそうです。

GAE の us-cental, europa-west リージョンが全体的に優秀です。 us-cental は AWS のどんなリージョンからでも 300ms 未満でリクエストを返しています。 europa-west リージョンも殆どのリージョンから 300ms 未満でリクエストを返しています。

asia-northeast1 に加えてこの2つのリージョンにサービスを配置すれば どんな AWS のリージョンからのリクエストでも 100 - 200ms 未満でリクエストを返すことができそうです。

エンジニア組織の成長に必要なのは、一人の情熱を大切にすることである

$
0
0

こんにちは、ゆのん(id:yunon_phys)です。この記事は Akatsuki Advent Calendar 2019 10日目の記事です。

エンジニア組織の成長のために大切にしている2つの事柄

アカツキのエンジニア組織は2~3年かけて成長していく状態を目指しています。 そしてその成長のためには、情熱技術の積み上げが大事である、と考えています。

1. 情熱という感情を大切に扱う

アカツキでは、情熱を持って仕事をしている状態を称賛します。 というのも、その人の想いが込められたプロダクトは明らかに完成物のクオリティが高くなりますし、よりクオリティを上げるためのいかなる努力も惜しまなくなり、結果として人も組織も成長すると考えているからです。

情熱というのは大きな野望である必要はありません。 その人が心からやりたいと思っているものであれば、その情熱の炎に大きさは関係ありません。 個人としてはその炎に絶えず薪をくべて大切に育てて欲しいですし、 組織としてはその炎が消えないように他からガードし、時には燃料の供給となるようにその機会を与えられるよう支援します。

2. 技術の積み上げを称賛する

技術はある日突然飛躍的に進歩するものではありません。 必ずその背景には様々な技術があり、地道な努力だったり、挑戦の結果の証として、今の技術が成り立っています。 アカツキのプロダクトにおいても同じように技術の積み上げで成り立っています。

代表的な例を上げると、ゲームの運用技術の積み上げです。 他のゲームで採用したアーキテクチャやSaaSをそのまま使えるところは使い、もっとこうすれば良かったというところは改良していきます。 マスターデータやアセットの管理方法も、過去の成功や失敗を元に、プロダクトにとってベストなやり方を短期間で検討出来るようになりました。

0から1を生み出す独創的なアイデアを持って課題解決出来る人はもちろん素晴らしいですが、 1を100にするために技術を積み上げられる人も組織では重要な役回りです。

アカツキのエンジニア組織の成長モデル

では、情熱と技術の積み上げがどのように組織の成長につながるのでしょうか。 以下に、アカツキのエンジニア組織がどのように成長していくかを図示します。

f:id:yunon_phys:20191210102612p:plain

ここでいう"良いプロダクト"とは、高い売上のあるプロダクトだけを意味しておらず、

  • 会社の幅を広げるプロダクト
  • 会社の将来の柱となるような技術を活用したプロダクト
  • システムが安定しているプロダクト
  • 熱狂的なファンがついているプロダクト

などもひとくくりにして"良いプロダクト"と言っています。

では、これらのサイクルがうまく回るように、アカツキのエンジニア組織はどのように担保しているのでしょうか。 上記のサイクルを一つずつ分解していきましょう。

情熱はチャレンジを生む

f:id:yunon_phys:20191210103055p:plain情熱を持った人は、自分に対して、組織に対して、あるいは世界に対して新しいチャレンジをします。 その熱い気持ちが逃げないように、以下のような施策で担保しています。

メンバーのWillを活かすアサイン

新卒エンジニアの配属や異動は、本人がこのチームに行きたい・この技術を使いたい、というWillを大事にします。 というのも、やりたいことをやっている状態は、最もパフォーマンスが出て結果バリューにつながると信じているからです。

異動を希望してきた場合は、今ある業務との兼ね合いもあるので、どうしたら今の業務を切り離せるのか、 誰にいつ業務を引き継ぐのか、何を自動化して業務を減らすのか、などを丁寧にすり合わせします。 また、いつ新しいチャレンジをしたいという気持ちになるかもわからないので、常に自分の業務を他の人に渡せるように行動しよう、というのは目標設定時に私から伝えています。

少額投資プロジェクトの推進

アカツキでは、少額の範囲であれば、役員の承諾不要でプロジェクトを立ち上げられるようになっています。 実際に、新卒のエンジニアが企画・デザイン・開発・検証・他社とのコミュニケーションまでを一人で行い、カジュアルゲームをリリースに挑戦するというのを過去に実施したことがあります。 先輩社員がサポートする場面も勿論あるのですが、業務命令でではなく、情熱によるチャレンジを後押ししたくてサポートしている例が多いです。 そういう意味で、情熱は伝播していくものなのかもしれません。

チャレンジは新しい知見を生む

f:id:yunon_phys:20191210103201p:plainチャレンジをして成功したのであればそれは成功体験として勿論新しい知見になりますが、一方で、成功ばかりではなく失敗もあります。 その失敗をただの失敗で終わらせるのではなく、そこから学びを得て新しい知見にするために、失敗を財産にする文化があります。

失敗を財産にするために、ポストモーテムを実施する

これまでアカツキも様々なプロジェクトで失敗してきました。 この失敗の度に何が問題だったのかを議論し、時にはそのときの感情もシェアしながら、次のプロジェクトや、既存のプロダクトに活かそうとしました。 それらの共有があったからこそ、他のプロジェクトでも危うく同じ失敗を繰り返しそうになったが防げたケースを何度も見てきています。

新しい知見は良いプロダクトを生む

f:id:yunon_phys:20191210103302p:plain過去の失敗経験や、ベストプラクティス、新技術などは良いプロダクトを生むために欠かせないですが、それを下支えするのが心理的安全性です。 心理的安全性が無いと、誰でも気軽に意見が言えなくなり、良いプロダクトが生まれづらくなってしまいます。 その心理的安全性をエンジニア組織が担保しているのが、フラットな組織づくりとHRTです。

フラットな組織づくりで上司をつくらない

上司や部下という言葉を徹底的に排除し、序列を作らないようにしています。 もちろんリーダーやマネージャーというのは存在はしますが、それはあくまで役割であって、上司と位置づけていないです。 CTOやVPoEも偉いわけではなく、あくまでその役割を全うするために権限・責任を行使するだけです。*1

HRTを共通言語にする

HRTという言葉は謙虚(Humility)、尊敬(Respect)、信頼(Trust)の頭文字を取った略で、Team Geekの中で出てくる言葉です。

Team Geek ―Googleのギークたちはいかにしてチームを作るのか

Team Geek ―Googleのギークたちはいかにしてチームを作るのか

謙虚(Humility): 世界の中心は君ではない。君は全知全能ではないし、絶対に正しいわけでもない。常に自分を改善していこう。

尊敬(Respect): 一緒に働く人のことを心から思いやろう。相手を 1 人の人間として扱い、その能力や功績を高く評価しよう。

信頼(Trust): 自分以外の人は有能であり、正しいことをすると信じよう。そうすれば、仕事を任せることができる。

アカツキのエンジニア組織ではこのHRTをもった行動が取れることを、現場で働くための最低限の条件にしていて、評価制度の中に組み込んでいます。

良いプロダクトは会社の魅力を上げる

f:id:yunon_phys:20191210103409p:plain良いプロダクトが生まれていくと、会社の魅力が上がっていきます。 しかしそれだけではどのような技術を使っているのか、どのような人が働いているのかはわからないので、社外発表(技術プレゼン)を推進しています。*2

社外発表を通してプロダクトと会社の魅力を伝える

2019年は、Erlang & Elixir Fest 2019AWS Summit Tokyo 2019CEDEC 2019Google Cloud Next '19 in TokyoGDC 2019等でアカツキから登壇しました。

技術の活用方法を公開すると、他社もそれを参考にして技術に磨きがかかり、さらにその技術をまたアカツキが参考にして・・・というループが回っていくので、これからもアカツキの使っている技術をどんどん公開していきたいところです。

会社の魅力が上がると、新しいメンバーが入ってくる

f:id:yunon_phys:20191210103532p:plain会社の魅力を上げると新しいメンバーが入ってきやすくなりますが、組織としては魅力を伝え続けること、そしてそもそも今現在組織が魅力的なのかを検証する必要があります。 そのためにやっているのが、スポンサーとリファラル採用です。

スポンサーでコミュニティに還元する

2019年は、RSGT 2019RubyKaigi 2019Ruby Associationbuilderscon tokyo 2019Unite Tokyo 2019DevLOVE XTHE LINUX FOUNDATIONCLOUD NATIVE COMPUTING FOUNDATIONVimConf 2019Rails Girls技術書典6技術書典7EOF2019CODE BLUE @TOKYOXR Kaigi 2019Wandboxにスポンサーをさせていただきました。

アカツキのお世話になっているOSSやコミュニティに幾分かご支援出来たことは光栄です。

リファラル採用を活用して組織の健康診断をする

リファラル採用は2016年度頃から積極的に開始し、今では全体の約20%程がリファラル採用です。 リファラル採用は既存メンバーが会社に対してポジティブな感情を抱いていないと成り立たないので、会社が本当に魅力的であるかを検証するのに格好の材料です。 急にリファラル採用の紹介件数が減るようになると、それは会社の魅力が落ちているサインかもしれないです。

新しいメンバーが入ってくると、また新しい情熱の波がやってくる

f:id:yunon_phys:20191210103644p:plain新しいメンバーはモチベーションが高い状態で入ってくるため、新しい情熱が会社に入ってきます。 このモチベーションからの情熱、そしてその後のチャレンジにつなげるために必要なのが、面接でのコミュニケーションだと考えています。

面接からオンボーディングは始まっている

面接は企業側が評価する側、候補者側がされる側という関係性になりやすいですが、お互いに評価される関係性である、と捉えるようにしています。 特に、やりたいことが会社で出来るのか、挑戦機会を組織が与えられるのか、期待値がお互い適切なのか、等を面接時点から可能な限り丁寧に擦り合わせます。

新しい知見を組織として積み上げる

f:id:yunon_phys:20191210110637p:plain新しい知見がプロダクトやプロジェクトにすぐに活かせるとは限りません。 その知見を自分の中や特定のチームに留めておくのではなく、組織として積み上げる必要があります。 そこでアカツキのエンジニア組織が用意している制度が、社内コミュニティ制度です。

社内コミュニティ制度が横の連携を強くする

アカツキは伝統的にプロダクト同士のエンジニアの交流が浅いという特徴を持っていました。 このため、プロダクトチーム内では知見が溜まっているが、その知見が組織全体に溜まっている状態ではありませんでした。 そこで、今年の夏頃より、社内コミュニティ制度を立ち上げました。 この制度は、エンジニア組織が社内コミュニティのある種スポンサーとなり、プロダクトチームの枠を超えた技術交流の活性化を図ったものです。

具体的には、以下のような補助を整備しました。

  • ランチ/ディナー補助
  • ツール/物品購入補助
  • 勉強会/カンファレンス参加補助
  • 強い人を呼ぶときの講演費用補助

残念ながらまだこれらが積極的に活用されている、という状態ではないですが、時間をかけて活性化していければいいなというところです。*3

技術の積み上げは情熱を巻き起こす

f:id:yunon_phys:20191210110303p:plain技術をある程度個人で積み上げていくと、既存のプロダクトでは満足しきれなくなり、別のプロダクトでその培った技術を使って課題解決したい、という情熱を向けてくるメンバーがいます。 それを強くてニューゲームと表現をしたりします。 積み上がった技術にチームの変化が掛け算されると、新しい情熱を生むと考えていて、異動を重要な組織施策だと考えています。

組織を常にアップデートし続ける

現在のアカツキのエンジニア組織が何を考え、どのように運営しているかを述べました。 一人の情熱からスタートして、技術が積み上がり、また情熱に還ってくるというのが現在の成長モデルです。 元からこのモデルを描いていたわけではなく、こうしたらもっと良くなるんじゃないか、何が成長の起爆剤なのかの思考実験を繰り返していった結果です。 数年後には今のままでは足りない部分を補強する施策が出来ているでしょうし、あるいは全く異なる成長モデルを作っているかもしれないです。

重要なのは組織をアップデートし続け、変化に適合していくことです。 失敗したらふりかえってまたやり直すよう向き直れば良いだけです。 こういった組織づくりをすることそのものが組織としての積み上げであり、これを書いている私の情熱でもあります。

*1:詳しくは過去の記事参照

*2:私だけかもしれないですが、プロダクトに面白い技術や先進的な技術が使われていると、それだけでそのプロダクトの魅力が増す感覚があります

*3:もしあまり活用されないようであれば取りやめますよ、ということは公言しています

ゲームプログラミングのHP計算システムアンチパターン

$
0
0

この記事は Akatsuki Advent Calendar 2019 11日目の記事です。

はじめに

アカツキでクライアントエンジニアをやっている tomotaka-yamasakiです。
私が所属しているプロジェクトは長く運用されているタイトルなので、実装当時に書かれたソースコードでは到底実現できない開発を迫られることがあります。*1

この記事では、ゲームのバトル中に行われるHP計算周りの改修を行ったときに踏んだアンチパターンと、最終的に行き着いた設計についてまとめています。バトルが存在するゲームになくてはならないHP計算システムについて、考えるきっかけになればと思います。

TL;DR

C++を前提とした記事です。
既存のHP計算システムでは実現できない仕様が追加されたのでリファクタリングに踏み切りましたが、苦しみました。
この記事ではHP計算システムの4つのアンチパターンとそれぞれの課題点を紹介しています。

  1. int型で定義されたHPに対して直接加減算する
    1. 課題: HP変動要素がキャンセルされる可能性
  2. HPを直接書き換えるsetterを導入する
    1. 課題: HP変動の種類
  3. HP変動の種類ごとにint型を保存し、計算する
    1. 課題: HP変動の順序性
  4. HP変動の種類ごとにフェーズに区切って変動値を計算する
    1. 課題: HP変動時の厳格なclamp処理

更に、これらの課題を解決する設計についても詳しく触れています。一言で表すと、HP変動ごとにインスタンスを生成し、順序性を保った上でclamp処理をかけながら計算するシステムです。

HP計算について考える

HPとは

ゲームでHPと書くと大抵ヒットポイントのことをイメージすると思います。
先頭に参加しているキャラクターの生命力のことをHPと称し、それが0になるとキャラクターは戦闘に参加できなくなるのが一般的です。*2
私の開発しているタイトルのHPも例外無く、その仕様です。HPは整数値で扱われるためint型で表されることが多いと思います。
ここからはC++を前提としたコードで説明していきます。

アンチパターン

1. int型で定義されたHPに対して直接加減算する

シンプルなターン制のバトルシステムを例に取ると、HPは敵から攻撃されると減少し、回復アイテムなどにより増加します。HPの増減計算は一方向で行われ、巻き戻ることはありません。

単純な一方向のHP計算

この場合HPはint型の変数として持っておくだけで充分仕様を満たすことができます。

class Player {
public:
    int getHp() const { return _hp; }
    void damage(constint damage) { setHp(_hp - damage); }
    void heal(constint heal) { setHp(_hp + heal); }

private:
    void setHp(constint hp) {
        // 詳細割愛: HPのmin,maxを超えない範囲で_hpにセットする
    }

    int _hp;
};

都度_hp変数に値を加減算し、現在のHPが知りたい時はgetHpするだけで事足ります。しかし、これは一方向のHP計算しかない場合にのみ有効です。発動タイミングが異なる回復スキルやキャンセル可能な回復スキルが出てくると破綻します。
つまり、「HP変動要素がキャンセルされる可能性」を考慮できていません。

2. HPを直接書き換えるsetterを導入する

プレイヤーの回復スキルが発動したときにHPが回復するとします。そのスキルには以下の仕様があります。

  • プレイヤー操作により、発動したスキルがキャンセルされる場合がある
  • 発動したスキルのリストは別クラスが保持しているため、回復値はそのリストから計算する
  • 発動するタイミングはスキルによって異なる

キャンセルが発生するため、HP計算に巻き戻りが生じています。そのため、スキル発動前のHPを記憶する変数が外部に存在し、スキルがキャンセルされた場合にその変数を用いてHPをリセット、変動値の再計算が行われていました。

回復がキャンセルされるパターン

class Player {
public:
    int getHp() const { return _hp; }
    void damage(constint damage) { setHp(_hp - damage); }
    void heal(constint heal) { setHp(_hp + heal); }

    // publicにして、強制的にHPをリセットするsetterを作るvoid setHp(constint hp) {
        // 詳細割愛: HPのmin,maxを超えない範囲で_hpにセットする(clamp処理)
    }

private:
    int _hp;
};

setHp関数は以下のように呼ばれていました。

// スキルがキャンセルされたタイミングで呼ばれるリセット処理void resetHp() {
    // _resetHp: ターン開始時のHP// _itemHealHp: アイテムによって回復したHP
    _player->setHp(_resetHp + _itemHealHp);
    auto skillHealedHp = calcSkillHealedHp();
    _player->heal(skillHealedHp);
}

このsetterでHPを自由に書き換えられてしまうため、複雑さが一気に増しています。しかもPlayerではない外部クラスがHPの情報を持っていてカプセル化もできていません。良くない方向に転がっています。
この場合、「HP変動の種類」をPlayerクラスで考慮できていないのが問題です。

3. HP変動の種類ごとにint型を保存し、計算する

諸悪の根源であるsetHpをprivateに戻し、HPに関係する変数は全てPlayerクラスに閉じ込めることにします。その後、HP回復値を加算し続けるもの、巻き戻る可能性があるものに分類して保持しましょう。
HP変動は以下の2つに分類が可能です。

  • 同一ターン内で加算し続ける回復値:
    • アイテムによる回復や発動キャンセルできないスキルによる回復
  • キャンセルされる可能性のある回復値:
    • キャンセルが可能なスキルによる回復

この2種類のHP変動値を管理するmapをPlayerクラスで保持します。また、回復を行う関数も改修します。回復関数内では以下の処理を行います。

  1. HP変動の種類によって、変数に対して加算 or 上書きを選択する
  2. 「ターン開始時のHP + このターンで変動した値」を現在のHPにする

詳細は割愛しますが、ダメージを与える関数も修正します。

class Player {
public:
    int getHp() const { return _hp; }

    void damage(constint damage) {
        _startTurnHp -= damage;
        setHp(_startTurnHp + getHealHpInTurn());
    }

    void healInTurn(HealHpCalcType healHpCalcType,
                    constint heal) {
        switch (healHpCalcType) {
            case HealHpCalcType::ADD:
                _healHpInTurn[healHpCalcType] += heal;
                break;

            case HealHpCalcType::OVERWRITE:
                // スキルは上書き// (ターン内で解除される可能性があるため、// 発動しているEfficacyInfoから毎回計算している)
                _healHpInTurn[healHpCalcType] = heal;
                break;
        }

        setHp(_startTurnHp + getHealHpInTurn());
    }

    enumclass HealHpCalcType {
        ADD,        //!< ターン内で加算し続ける
        OVERWRITE,  //!< 都度計算し最新の値に更新する
    };

private:
    void setHp(constint hp) {
        // 詳細割愛: HPのmin,maxを超えない範囲で_hpにセットする(clamp処理)
    }

    int getHealHpInTurn() {
        int allHealHp = 0;
        for (auto itr = _healHpInTurn.begin(); itr != _healHpInTurn.end(); itr++) {
            allHealHp += itr->second;
        }
        return allHealHp;
    }

    int _hp;                                        //!< 現在のHPint _startTurnHp;                               //!< ターン開始時のHP
    std::map<HealHpCalcType, int> _healHpInTurn;    //!< ターン内で回復したHP
};

setHpをprivateにし計算をPlayerクラスに閉じ込めることで、スキルキャンセル時に呼び出されていたHPリセット処理が不要になりました。ただ、この処理は回復に比重を置きすぎているため、ダメージを与える際にstartTurnHpから直接減算するなど良くないコードが目立ちます。setHpも本当に必要なのかよく分かりません。
更に、詳しくは後述しますがこのコードは「HP変動の順序性」が考慮できていません。

4. HP変動の種類ごとにフェーズに区切って変動値を計算する

「HP変動の種類ごとにint型を保存し、計算する」で回復とダメージのインターフェースは統一されましたが、2つの問題があります。

  1. HP全回復からダメージを受けた時、それまで上限で打ち止めされていた回復スキルが発動し、ダメージが軽減されてしまう
  2. 回復HPを計算し、そのHPが加算なのか上書きなのかを外部クラスが逐次判断しなければならない

回復やダメージの順序性の問題が発生しています。HP全快の状態ではもちろん回復できないのでHPは増えませんが、回復スキル自体は発動しています。そのスキルによる回復がダメージを受けた後に発動してしまうという問題です。ユーザには、「スキルによる回復 → ダメージ」という順番に見えていますが、内部ではスキルによる回復計算時にスキル発動タイミングが一切考慮されていないため、ダメージ前に発動していたスキルの回復値がダメージ計算後に反映されています。

HP計算の順序性

なので、順序を考慮した設計にします。
インターフェースを3つ用意しました。

syncPlayerHp: HPの同期をとる
applyDealDamage: ダメージを反映させる(ダメージ値を蓄積する変数を更新)
addPlayerBaseHp: スキル以外の回復値を反映させる(_startTurnHpに加算)

スキルによるHPが変動する可能性のある箇所ではsyncPlayerHpを呼び、発動しているスキルから毎回回復値を計算します。回復値を保持していた_healHpInTurnは撤廃しています。

/** * HPが変動する可能性があるタイミングで呼び出し、HPの同期を取る * 回復タイミングを3つのフェーズに分解し、各フェーズでの計算終了時にHPをclampする */void syncPlayerHp() {
    // 1: ターン開始時のHP + 攻撃フェーズまでに発動したスキルによる回復値auto healHpBeforeAttack = calcSkillHealedHp(/*攻撃前だけ計算するためのパラメータ注入*/);
    auto playerHp = _startTurnHp + healHpBeforeAttack;
    setHp(playerHp);

    // 2: ダメージ値を反映
    playerHp = getHp() + _damagedValue;
    setHp(playerHp);

    // 3: 攻撃フェーズ中に発動したスキルによる回復値auto healHpInAttack = calcSkillHealedHp(/*攻撃中だけ計算するためのパラメータ注入*/);
    playerHp = getHp() + healHpInAttack;
    setHp(playerHp);
}

回復タイミングを3つのフェーズに分解し、それぞれのフェーズでHPをclampし、min,maxを超えない範囲でHPを更新しています。それにより、それまで上限で打ち止めされていた回復スキルはフェーズ1で計算が終了しているためダメージを受けた後に反映されることがなくなりました。また、回復値のキャッシュも止めたので、外部からは変動する可能性のある箇所でsyncPlayerHpを呼ぶだけで良くなりました。

これでHP計算に順序性を持たせることができました。しかし、まだ考慮できていない点があります。
それは「HP変動時の厳格なclamp処理」ができていない問題です。

最終的なHP計算システム設計

これまでの問題点のまとめ

解説する前に最後のアンチパターンプログラムの問題点を挙げます。そのプログラムには「攻撃フェーズまでにダメージを受けることが無い」という前提が隠れています。仮に今のシステムで攻撃前に自傷ダメージ*3を受けた場合、回復アイテムで回復したとしてもその後に自傷ダメージが計算されるため、ユーザの行動によるHP変異とシステム上のHP変異に差が生まれます。

従って、HP変動順序を厳格に守る必要があり、更に1回のHP変動ごとにclamp処理を行わなければ上記仕様は成立しません。
ここでHP計算で考慮すべき点をおさらいします。

  1. HP変動要素がキャンセルされる可能性
  2. HP変動の種類
  3. HP変動の順序性
  4. HP変動時の厳格なclamp処理

この4つを実現するHP計算システムを再設計しました。

クラスの概要

一言で表すと、「HP変動ごとにインスタンスを生成し、順序性を保った上でclamp処理をかけながら計算する」システムを作りました。

  • HPの変動情報を持つエンティティクラスを作成
  • HPの計算を全て管理するクラスを作成
    • このクラスが変動情報を持ち、管理する
  • スキルによる回復以外も全て同じ変動情報エンティティとして管理し、区別しない
/** * HP変動値の計算に必要な要素 */class HpCalculationFactor {
public:
    enumclass Type {
        SKILL,              // !< スキルによる変動
        NOT_RECALC,         // !< スキル以外による変動
        DAMAGE,             // !< ダメージによる変動
    };
    struct SkillIdentifer {
        int index;
        int skillId;
        SkillIdentifer(int index, int skillId)
        : index(index)
        , skillId(skillId)
        {}
        booloperator ==(const SkillIdentifer& other)
        {
            return index == other.deckIndex && skillId == other.skillId;
        }
    };
    HpCalculationFactor(int diffHp, SkillIdentifer skillIdentifer);
    HpCalculationFactor(int diffHp, Type type);
    virtual ~HpCalculationFactor() = default;

    int calcDiffHp(constint playerHp, constint playerMaxHp);
    int getDiffHp() const { return _diffHp; }
    SkillIdentifer getSkillIdentifer() const { return _skillIdentifer; }
    Type getType() const { return _type; }
    bool isActive() const { return _isActive; }
    void inactivate();
    void update(int diffHp);

private:
    int             _diffHp;            // 変動する可能性のあるHP
    SkillIdentifer  _skillIdentifer;    // スキルによるHP変動のみidが存在する
    Type            _type;              // 変動の要因となったものbool            _isActive;          // 変動要素が機能しているかどうか
};
using HpCalculationFactorPtr = std::shared_ptr<HpCalculationFactor>;

/** * HP変動値の計算を行うクラス */class HpCalculator {
public:
    HpCalculator();
    virtual ~HpCalculator() = default;

    void reset(constint playerHp);
    int calculate(constint playerMaxHp);
    void setSkillFactors();
    void setFactor(constint factorValue, HpCalculationFactor::Type factorType);

private:
    void gc();
    int total();
    void inactivateSkillFactor();

    std::list<HpCalculationFactorPtr> _factors;
    int _startTurnHp;
};

クラスの詳細解説

HpCalculationFactorクラス

このクラスはHPを変動させるためのエンティティクラスです。
今までは数値としてでしか管理できていなかったHP変動要素をオブジェクト指向らしくクラス化しました。HPを変動させる可能性のある値はもちろんのこと、calcDiffHp関数を呼ぶことでclamp処理を行った本当の変動値を取得可能にしています。また、1スキルごとに固有のIDを持たせているため、そのスキルの内容が途中で変更された場合はエンティティを特定し更新することができます。

/** * 実際に変動するHP値を計算する * @param[in] playerHp 現在のプレイヤーHP * @param[in] playerMaxHp プレイヤーの最大HP * @return 現在のプレイヤーHPを加味した変動HP値を返す */int HpCalculationFactor::calcDiffHp(constint playerHp, constint playerMaxHp)
{
    auto hp = playerHp + _diffHp;
    if (_type == Type::DAMAGE) {
        hp = std::min(std::max(hp, 0), playerMaxHp);
    } else {
        hp = std::min(std::max(hp, 1), playerMaxHp);
    }
    return hp - playerHp;
}
HpCalculatorクラス

このクラスはHpCalculationFactorクラスのインスタンスを順序性のあるstd::listで管理しています。
calculate関数で現在のHPを計算します。関数の中身は単純で、リストのFactorから変動値を取得、合算し_startTurnHpに加算しているだけです。

/** * 登録されているHP変動要素から、最終的なHP値を算出する * @param[in] playerMaxHp プレイヤーの最大HP * @return プレイヤーHPのmin,maxを加味したHP変動値を算出する */int HpCalculator::calculate(constint playerMaxHp)
{
    gc();

    auto playerHp = _startTurnHp;
    for (auto factor : _factors) {
        auto diff = factor->calcDiffHp(playerHp, playerMaxHp);
        playerHp += diff;
    }
    return playerHp;
}

Factorの登録はsetFactorとsetSkillFactorsで行います。setFactorはスキル以外のHP変動要素があったときに呼びます。変動する値と種類を元にFactorを生成し、リストに格納します。
スキルによる回復はsetSkillFactors内で計算されます。発動しているスキル一覧は別クラスが保持しているためその情報を元にFactorを生成します。もし既にFactorとして登録されているスキルがあった場合新しくFactorは登録せず、リストの順序を保ったままFactorの内容だけ更新します。

/** * スキル以外のHP変動要素をcalculatorに登録する * @param[in] factorValue HP変動値 * @param[in] factorType HP変動の要因となったもの */void HpCalculator::setFactor(constint factorValue, HpCalculationFactor::Type factorType)
{
    auto factor = std::make_shared<HpCalculationFactor>(factorValue, factorType);
    _factors.push_back(factor);
}

/** * スキルによるHP変動要素を生成し、calculatorに登録する * 既にFactor登録されているスキルは内容だけ上書きする */void HpCalculator::setSkillFactors()
{
    // スキルは発動していれば再計算可能なので一度inactiveにする
    inactivateSkillFactor();

    // 中略...for (auto& skill : skillList) {
        // 中略...auto skillIdentifer = HpCalculationFactor::SkillIdentifer(skill->getIndex(), skill->getSkillId());
        auto factor = std::make_shared<HpCalculationFactor>(healHp, skillIdentifer);
        auto itr = std::find_if(_factors.begin(), _factors.end(), [factor](const HpCalculationFactorPtr& f) {
            return f->getSkillIdentifer() == factor->getSkillIdentifer();
        });
        if (itr != _factors.end()) {
            (*itr)->update(factor->getDiffHp());
        } else {
            _factors.push_back(factor);
        }
    }
}
HpCalculatorクラスのgcについて

途中でキャンセルされたスキルのFactorはinactive状態になります。inactive状態になったFactorはcalculate関数を呼んだときに自動的にgcされ、計算から除外される仕組みを実装しています。

/** * 機能しなくなっているHP変動要素があればリストから削除する * @detail 一部スキルはターンの途中で消える場合がある */void HpCalculator::gc()
{
    _factors.remove_if([](const HpCalculationFactorPtr& factor) {
        return !factor->isActive();
    });
}
HPの同期を取るsyncPlayerHp

syncPlayerHp関数があるクラスでは順序性を考慮しなくても良くなったのでスキルによるHP変動のFactorを登録し、calculateを呼ぶだけのシンプルな関数になりました。

void syncPlayerHp()
{
    _hpCalculator->setSkillFactors();
    auto playerHp = _hpCalculator->calculate(getPlayerHpMax());
    setPlayerHp(playerHp);
}

まとめ

この記事では4つのアンチパターンとそれぞれの課題点を紹介しました。

  1. int型で定義されたHPに対して直接加減算する
    1. 課題: HP変動要素がキャンセルされる可能性
  2. HPを直接書き換えるsetterを導入する
    1. 課題: HP変動の種類
  3. HP変動の種類ごとにint型を保存し、計算する
    1. 課題: HP変動の順序性
  4. HP変動の種類ごとにフェーズに区切って変動値を計算する
    1. 課題: HP変動時の厳格なclamp処理

これらを満たすためには単純に整数値を加減算するだけではなく、変動要素クラスとそれを管理する計算クラスを実装すべきです。
特に順序性やclamp処理は深く考えずに実装するとどこかで考慮漏れが発生します。オブジェクト指向型言語であればできるだけカプセル化しましょう。
HPclamp処理は1つの変動要素を計算するたびに行うようにすると計算のズレが発生しません。
要するに、HP変動ごとにインスタンスを生成し、順序性を保った上でclamp処理をかけながら計算すると良いかと思います。

またこの記事では触れていませんが、HP計算を更に複雑にさせる仕様は存在します。
例: 無敵状態の場合HPは変動させない、自傷ダメージではHPは0にならない など*4
そのような仕様があったとしても変動要素クラスさえあれば、変動値を取得する関数内で計算することで処理を隠蔽することが可能です。変動する値とその種類を同クラス内で持つことはそういった利点があります。

HP計算はバトル要素のあるゲームに必須なので、この記事が誰かのお役に立てれば幸いです。


*1:エンジニアはできる限り柔軟に対応できるソースコードを書くことに尽力しますが、時代ともにユーザのニーズは刻一刻と変化するため、エンジニアの予想を遥かに超えた要求仕様を頑張って実現しなければいけない場面に稀に遭遇します。

*2:ヒットポイントでありながらライフポイントの意味合いで使われているのは今更ながら不思議ですね。

*3:自傷ダメージを受けてステータスアップ、みたいなスキル、ありそうですよね。

*4:この記事を書いている途中でHP(ヒットポイント)をどう作る?という記事を見つけました。そこで書かれている「残っている体力の割合計算が手間」というのもあるあるです。int型ではなくあえてfloat型で管理する手段も良さそうです。

UnityのVariant機能をつかってちょっと躓いた話

$
0
0

はじめに

この記事は Akatsuki Advent Calendar 2019 13日目の記事です...でした。 まだUTC-8くらいまでは13日なのでセーフったらセーフです。

挨拶が遅れました。アカツキでクライアントエンジニアをしている shairo_jpこと下村です。

師も開発者も忙しく走り回る年末に皆さんいかがお過ごしでしょうか。

UnityのAddressablesからpreviewが外れてそろそろ半年ほども経つようですね。 巷のUnityプロジェクトはもうAssetBundleを脱出する算段をつけている頃合いかと思います。

私もAddressableに関する記事を投稿する腹積もりでしたが、少々アテが外れたためAssetBundleに関する小さなTipsを共有することにしました。 もうAssetBundleはだいぶ触り尽くしたと思っていたのですが、Variantの取り扱いで躓いた点があったので紹介します。

Variantとは

VariantはUnityのAssetBundleの機能の一つで、AssetBundleの参照関係を壊さないようにアセットを置き換えるための仕組みです。 もっぱらSD/HDアセットや言語の切り替えといった用途に利用されます。 ここではVariantについて詳しい説明はしません。後の説明に必要な部分だけに留めます。

f:id:shairo_jp:20191214163827p:plain

BundleAに含まれるPrefabは BundleBのImageアセットを参照しています。 今回はこのImageを切り替えられるようにしたいので、 BundleBにXとYのVariantを用意します。

f:id:shairo_jp:20191214165532p:plain

Variant違いのアセットは同じ名前と内部IDを持つため、 BundleB.Xの代わりに BundleB.YをロードすればPrefabが参照するアセットが自然に切り替わります。

VariantをサポートするUnity公式のAssetBundleManagerを見てみましょう。

// Get dependecies from the AssetBundleManifest object..
string[] dependencies = m_AssetBundleManifest.GetAllDependencies(assetBundleName);
if (dependencies.Length == 0)
    return;

for (int i = 0; i < dependencies.Length; i++)
    dependencies[i] = RemapVariantName(dependencies[i]);

// Record and load all dependencies.
m_Dependencies.Add(assetBundleName, dependencies);
for (int i = 0; i < dependencies.Length; i++)
    LoadAssetBundleInternal(dependencies[i], false);

Unity-Technologies / assetbundledemo / demo / Assets / AssetBundleManager / AssetBundleManager.cs — Bitbucketより引用

GetAllDependenciesで取得したAssetBundle名に対してVariant名のリマップを行っているようです。 これでめでたく読み込むImageアセットを切り替えることができました。

問題点

しかしVariantのAssetBundleに含まれるアセットもさらに他のAssetBundleのアセットを参照しているかもしれません。 もう少し複雑な次の例を考えてみましょう

f:id:shairo_jp:20191214164021p:plain

この時 GetAllDependencies("BundleA")["BundleB.X", "BundleC"]を返します。 しかしこのリストの BundleB.XBundleB.Yに置き換えても、 BundleB.Yに必要な BundleDが不足してしまいます。

このように、実はVariantを利用する場合にはGetAllDependenciesを利用することができません。 Variant名の解決の解決は、AssetBundleが 直接依存するAssetBundleに対して行う必要があります。

Variantを指定してDependenciesを取得する

気づいてしまえばあとは簡単です。ここは再帰呼び出しを利用して簡単にGetAllDependenciesの代替スクリプトを書いてみます。

public static string[] GetAllDependenciesWithVariant(this AssetBundleManifest manifest, string assetBundleName,
    IReadOnlyDictionary<string, string> variantMap)
{
    var dependencies = new HashSet<string>();
    GetDependencies(assetBundleName);
    return dependencies.ToArray();

    void GetDependencies(string name)
    {
        if (variantMap.TryGetValue(name.Split('.')[0], out var trueAssetBundleName))
        {
            name = trueAssetBundleName;
        }

        foreach (var dependency in manifest.GetDirectDependencies(name))
        {
            if (dependencies.Add(dependency))
            {
                GetDependencies(dependency);
            }
        }
    }
}

Variantの具体的な使い方は人それぞれなので、非Variant名からVariant名を取得できるようなテーブルを用意するのが柔軟でよいです。 今回の例では variantMapに以下のようなテーブルを渡します。

{
    {"BundleB", "BundleB.Y"}
}

AssetBundle名を受け取ったら、まずはSplitで末尾のVariantを取り除いて先程のテーブルを引きます。 Variant名を取り除いたらGetDirectDependenciesで依存するAssetBundleを取得し、既出でなければ再帰的に依存関係を調べます。

普段GetAllDependenciesを使っていると気づきませんが、AssetBundleの依存関係は循環することがため既出かどうかを判定しなければなりません。

なお、AssetBundle名にはピリオドを含めれないので、Variant以外でSplitに引っかかることはありません。

全てのVariantを含むDependenciesを取得する

突然ですが、AssetBundleの欠点の一つにそれをResourcesと同様に扱う事ができないという問題があります。 そのために主要な全てのアセットをAssetBundleに含め、起動から初回ダウンロードまでに必要なAssetBundleをStreamingAssetsに格納する、という設計がしばしば採用されます。

このときStreamingAssetsに格納するAssetBundleは、当然全ての依存関係を完全に含まなければなりません。 つまり、今度は全てのVariantを含む依存関係の解決を行う必要があります。

public static string[] GetAllRequirementsWithVariant(this AssetBundleManifest manifest, string assetBundleName)
{
    var allVariantsMap = manifest.GetAllAssetBundlesWithVariant()
        .GroupBy(n => n.Split('.')[0])
        .ToDictionary(g => g.Key, g => g.ToList());

    var requirements = new HashSet<string>();
    GetDependenciesWithAllVariant(assetBundleName);
    return requirements.ToArray();

    void GetDependenciesWithAllVariant(string name)
    {
        name = name.Split('.')[0];

        if (allVariantsMap.TryGetValue(name, out var variants))
        {
            foreach (var variant in variants)
            {
                GetDependencies(variant);
            }
        }
        else
        {
            GetDependencies(name);
        }
    }

    void GetDependencies(string name)
    {
        if (!requirements.Add(name))
        {
            return;
        }

        foreach (var dependency in manifest.GetDirectDependencies(name))
        {
            GetDependenciesWithAllVariant(dependency);
        }
    }
}

まずはGetAllAssetBundlesWithVariantで全てのVariantの対応表を作ります。 例では allVariantsMapは以下のようなテーブルになります。

{
    {"BundleB", {"BundleB.X", "BundleB.Y"}
}

あとはAssetBundle名からVariantを取り除き、改めてVariantを列挙しながら依存AssetBundleを列挙するだけです。 今回はルートのAssetBundleのVariantも含めたいので、 requirementsにルート自身を含めるようにしています。

おわりに

もう4ヶ月もすればUnity2019もLTSがリリースされ、Addressablesの実戦投入もグッと現実化するでしょう。 このTipsはもはや過去のノウハウですが、この間隙にまだAssetBundleに悩まされている開発者の助けになれば幸いです。

明日...いや今日は s-capybara さんの番になります。

Vim 8.2 リリース!同時に公開されたデモのプラグインを解説してみる

$
0
0

この記事は Akatsuki Advent Calendarの 15 日目の記事です。

thinca です。普段は Vim を使って開発をしています。

そんな Vim ですが、つい 2 日ほど前、待望の Vim 8.2 がリリースされました!やったね🎉

本記事では Vim 8.2 で何ができるようになったのかを、同時に公開されたデモプラグインを通して見ていこうと思います。

Vim のリリースについて

その前に、Vim の開発体制について少し説明します。 Vim の開発は GitHub の vim/vimリポジトリで開発されています。ブランチは master のみで、最新版は同時に開発版でもあります。

Vim は、パッチ(Git 管理になった今ではコミットとほぼ同義)を積み重ねて改善が行われます。前回のマイナーバージョンアップ(Vim 8.1)から少しずつパッチを積み重ね、ある程度のところでキリを見て新しいバージョン(今回の場合は 8.2)を振ります。

つまり Vim の HEAD を追いかけている人からすると、バージョンが 8.2 になったところで劇的に何かが変わるわけではありません。Linux の各ディストリビューションも、多くの場合はキリを見てパッチが入ったバージョンをリリースするので、気付いたら新しい機能が入った Vim を使っている場合もあります。

今回、ここで言う Vim 8.2 の新機能とは、Vim 8.1 リリース時点から足された機能のことを指します。上記の事情から、中にはもうずいぶん前から使えていた機能もあります。

デモプラグイン

さて、Vim の開発者である Bram さんは、今回 Vim 8.2 をリリースするにあたって Vim 8.2 で追加された機能のデモンストレーションをするためのプラグインを公開しました。

https://github.com/vim/killersheep

これは Vim 上で動作するゲームで、Vim 8.2 で追加された様々な機能が使われています。

動作の様子は以下のような感じです。

:KillKillKill Ex コマンドでゲームを開始し、画面下の砲台を操って羊の攻撃を避けつつ倒していきます。背景には編集中のテキストがそのまま残っています。とてもシュールですね。

ポップアップウィンドウ

Vim はウィンドウの中にバッファを表示することができ、ウィンドウは縦か横に分割することで複数のバッファを表示することができます。 Vim 8.2 では、新たにポップアップウィンドウがサポートされました。これはウィンドウの分割とは独立して、Vim 上の好きな位置にウィンドウを配置する機能です。IDE のようなプラグインを実装するために、ドキュメントや補完対象の情報をカーソルの近くに表示することができます。 ゲーム内では、羊や砲台がポップアップウィンドウとして実装されています。編集中のテキストの上でゲームが動くのもこれのおかげです。

テキストプロパティ

ゲーム中で砲台や羊に色が付いているのは、テキストプロパティを使っています。

ハイライト自体は今までの Vim でもできましたが、より直接的にハイライトを指定できます。

テキストプロパティにはテキストが編集された際にその位置が連動して動く性質もあるので、ハイライト箇所を絶対位置で指定するよりも柔軟な使い方が可能です。

サウンド

実際にゲームをプレイするとわかりますが、対応している環境であれば効果音が鳴ります。 Vim 8.2 では音を鳴らす機能が搭載されました。

テキストエディタに音を鳴らす機能なんているの? って思いますよね。私もそう思います。なんで入ったんだろう…。ゲームは作りやすくなったかもしれないですね。

まとめ

Vim 8.2 では、今後 IDE のような機能をサポートするための下地のような機能が多数入りました。LSP(Language Server Protocol)の発展も目が離せませんし、今後よりリッチな開発環境の整備が進むことはとても楽しみです。

古いだけじゃない、新しい Vim に皆さんも触れてみてはいかがでしょうか。

Viewing all 223 articles
Browse latest View live


<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>