mizdra's blog

ぽよぐらみんぐ

Vue.js+TypeScriptを試した際の雑感

Vue.js, 良いですよね. ドキュメントも充実しているし, 読みやすいし, 個人開発する分には素早く楽しく開発できてとっても良いライブラリだなと思っています.

ただ, 開発をしているとやっぱり「型が欲しい!」という気持ちが生まれてしまうものです. そういう経緯でVue.jsにTypeScriptを上手く結合しようと色々調べたので, ここではその時に出てきた雑感を箇条書きで纏めておきます. Vue.jsはダメだとかTypeScriptは悪とかではなくて, ただのメモだと思って読んでもらえればと思います. 「その問題, これで解決するよ」とか「こんな方法あるよ」とかあればコメントなり @mizdra へのリプライなりで教えて頂けるとありがたいです 🙏

前提

  • エディタ: VSCode
  • Webアプリケーションの個人開発をしたい
  • コンポーネント指向
  • 型が欲しい
    • クラッチになるのでFlowではなくTypeScriptで
  • 楽しく開発したい

雑感

要約

  • 問題が多く, まだ厳しい
  • 問題は沢山あるけど, 解決しようという動きはあるので継続して追っていきましょう

未解決の問題

解決済みの問題

代替手段

参考

2017年を振り返って

"今年"は2017年, "来年"は2018年のことを指します.


去年同様, 今年も1年の振り返りをします. 例年通り雑にやっていきます.

今年も年が明ける前に投稿出来なかった. 残念.*1

1月

この頃は去年の12月にやっていたReact+Reduxで****ツールを作る作業の続きをしていました.

12月後半はReact+Reduxで****ツールを作っていました. これは現在も製作中で, 暫くしたら公開できるかと思います. どうぞお楽しみに.

2016年を振り返って - mizdra's blog

結局時間があまり取れなくてこのプロジェクトの開発は停止中です… スマホから****ツールを使いたいという要望が多いのでWebアプリでサクッと作ってしまいたいという気持ちはあるので, 今後何かしらやるかもしれません. 多分…

ReduxというかFlux, この時初めて触ったのですが得られるものも大きいが失うものも大きい…という印象を受けました. ボイラープレート辛い… 最近Flux周りは一切触ってないので来年は色々試してみたいですね.

2月

2月のある日, うっかりcd src/c src/にtypoして大事故が発生しました.

これは, 僕のzsh環境ではalias c="git checkout"という一文字エイリアスが登録されており, c src/git checkout src/に展開されてsrc/ディレクトリの内容がHEADへと巻き戻される==コミットしていないsrc/ディレクトリの変更が吹っ飛んだという話です. git checkoutが破壊的なコマンドだなんて知らなかったんです… うう… *2

それとyysk.herokuapp.comという超便利Webサービスを発見しました. 最近はこれを使ってゆゆ式ライフを満喫していて完全にオタクです. 皆さんも一緒にゆゆ式ライフを満喫しましょう*3.

3月

Nintendo Switchが届いたので「ゼルダの伝説 ブレス オブ ザ ワイルド」というげーむをやりました. とっても楽しかったです.

あと, Dentoo.LT #16 で登壇してVue.jsの話をしました.

4月, 5月, 6月

この頃はサークルで「Scala Collection Library Code Reading」というScalaのコレクションライブラリのコードをひたすら読む会に参加していました. 4月中はScalaの基礎を学び, 5月以降からコレクションライブラリのコードを読んでいました. 僕は今までScalaに触れたことはありませんでしたが, おかげで複雑でないScalaコードであれば読み書きできる程度にはなりました. 「ListStreamの構造はこうなっているのか〜」, 「implicitってこうやって使うのか…」, 「call by need, loop detection, 戻り値同型の原則, なるほど…なるほど…」などと言っていました. 普段JavaScriptしか書いていないので関数型言語に触れられたのは非常に良い体験でした.

7月

イカを買ってしまいました*4.

Switch, 結局最近はイカ専用機になってしまっているのですが, イカは楽しいので満足度は非常に高いです. ゲーム起動して数分でサクッと遊べるの最高ですよね. 無限に時間が溶ける.

8月

この頃からEmtimerの開発をしていました.

開発の動機は既存のFlash製のタイマーをリプレースしたい等色々あったのですが, 単純にVue.jsが良さそうだったのでそれを使って何か作ってみたいなというものがありました. この考え自体は3月にVue.jsでタイマーを作る話をした時からあって, やっと夏季休業で時間が取れたので開発を始めたという感じです.

Vue.jsを触ってみた感想としてはとりあえずVue.jsが提供してくれている機能だけでも十分アプリケーションは作れるんだなあという感じです. ただ, (最新のバージョンで多少マシにはなりましたが)TypeScriptのサポートが微妙だったり, ツール周りのサポートが不十分(特にLintなどの静的解析周り)だったりしたので, TypeScriptでガチガチにやりたいならVue.jsはちょっと辛いかも…とは思いました. まあ公式もこのあたりの問題を改善しようと努力しているようなのでいずれ時間が解決してくれると思います.

それと家のトイレが新しくなりました. 扉を開けたらパカーッと便蓋が開くすごいやつです. 便器の中が光ったりします. ピカー.

Dockerの勉強もしました.

mizdra.hatenablog.com

9月, 10月

Headless ChromeのラッパーライブラリであるGoogleChrome/puppeteerが公開されたので, それを使って自動車学校の技能教習の予約が空いたら通知するスクリプトを書きました.

mizdra.hatenablog.com

そう言えばウェブスプレイピングやるの初めてでしたね. 個人的に満足のいくものが出来たので良かったです.

11月

11月は8月からコツコツ作っていたEmtimerを公開しました.

RT: 170+, Like: 350+(記事執筆時点) と非常に多くの反響がありました. ありがとうございます🙇🙇🙇 お陰様でEmtimerは40日程で約4000ユーザ*5が利用しています. セッション数にすると約1.4万件です. 今後も開発を継続していく予定ですので, どうぞよろしくお願いします.

mizdra.hatenablog.com

12月

Pokémon RNG Advent Calendarの季節です!!! 今年もやりました!!!

adventar.org

今年も無事埋まったので本当に良かったです*6. めでたい 🎉🎉🎉 参加してくださった方々, ありがとうございました🙇🙇🙇

僕が書いた記事は以下の3つです. 是非読んで下さい.

アニメを振り返る

今年からアニメの振り返りもしてみようと思います. 僕がこの1年で観たアニメで「良い」「良すぎる」などと感じたアニメを列挙するコーナーです. *7

良い << めっちゃ良い << 良すぎる << 最高 の順で評価が高いです.

  • 2017冬
    • リトルウィッチアカデミア(TVシリーズ) 1クール目: 最高
    • けものフレンズ: 良すぎる
  • 2017春
    • リトルウィッチアカデミア(TVシリーズ) 2クール目: 最高
    • 冴えない彼女の育てかた♭(まだ半分くらいしか観てない…): めっちゃ良い
    • エロマンガ先生: 良い
  • 2017夏
    • NEW GAME!!: 良すぎる
    • 徒然チルドレン: 良すぎる
    • メイドインアビス: 最高
  • 2017秋
    • Just Because!: 最高
    • 少女終末旅行: 最高
    • 宝石の国: 最高

秋は最高だった… 特に宝石の国は毎回最高って言っていた記憶があります. 早く2期が観たい…

GitHubを振り返る

去年

f:id:mizdra:20170101003302p:plain

今年

f:id:mizdra:20180101002819p:plain

去年から80 contributions程増えました. めでたいですね. 8月〜12月は大体Emtimer関連のcontributionsでした. この調子で来年も頑張っていきたいと思います.

おわりに

2017年は沢山頑張った気がします. 最近は自分の技術力が上がってきて開発スピードが以前よりかなり速くなったのを実感するようになりました. 良いことです. 2018年は2017年よりさらにやっていきを加速させていきたいと思います*8. 2018年もやっていきましょう.

*1:3年連続年内投稿失敗している気がしますが気のせいでしょう.

*2:この事故が発生して以降, 「alias c="git checkout"」は削除しました

*3:姉妹サービスに zoi.herokuapp.comがありますが, 記事執筆時点では落ちているようです. 残念…

*4:この行為が後にあんなことになるとはこの時はまだ誰も知らなかった― (イカのやり過ぎで無限に時間を消費するやつです)

*5:Google Analyticsより

*6:枠が予約されているものの書かれていない日がいくつかありますが, 気のせいです. 気のせいです.

*7:ちなみに僕が見たアニメはanimetickで管理しています.

*8:流石に雑すぎるので具体的な例を書いておくと, WebAssembly, Flux, サーバサイドあたりを触ってみたいと思ってます

Pokémon RNG Advent Calendar 2017 を振り返る

はじめに

この記事はPokémon RNG Advent Calendar 2017 25日目, 最終日の記事です. 今年はポケモンの最新作が完全新作ではなくマイナーチェンジだったこともあって「カレンダーが埋まりきらないのではないか」などの心配がありましたが, 無事埋まったのでひとまずホッとしています.*1 めでたい 🎉🎉🎉

adventar.org

この記事ではPokémon RNG Advent Calendar 2017を振り返り, 投稿された記事の中から, 個人的に興味を持ったものや面白いと思ったものをジャンルごとにピックアップします.

ツール

解析

乱数調整

数学

  • 64bit LCGの検索 - oupoの日記
    • 64bitLCGにおいて, 乱数値16bitとその10個先の乱数値16bitから高速にseedを求めるアルゴリズムの提案です
    • 普通64bitのseedを全探索で求めようとするとかなりの計算時間が必要ですが, この手法を使うとわずか20秒程で求めることができます

プログラミング

おわりに

以上が Pokémon RNG Advent Calendar 2017 25日目「Pokémon RNG Advent Calendar 2017 を振り返る」となります. いかがでしたでしょうか. 是非ここで紹介した記事を読んで今後の乱数調整活動「ランカツ!」の参考にして頂ければと思います.

それでは2017年も Pokémon RNG Advent Calendar を楽しんで頂き, ありがとうございました 😄 またいつかお会いしましょう!

adventar.org

様子

*1:埋まって入るものの記事が書かれていない日が多々あるようですが目を瞑りましょう.

Scalaで乱数ツールを書く話

はじめに

この記事はPokémon RNG Advent Calendar 2017 10日目の記事です.

adventar.org

乱数調整で楽しむ方々の間では乱数調整を支援するツールのことを乱数ツールと呼んでいます. 僕も乱数ツールを作成する内の1人であり, 時々ツールを作成しますがツールのソースコードはどうしても複雑になりがちです. たかが計算ツールといえども綺麗に書きたいですよね.

この記事ではScalaを使い, 乱数ツールを綺麗に書いてみる話をします.

…あれ?🤔

Adventarのコメント欄にはデザインパターンの話をするって書いてあったはずなのにScalaの話?🤔

デザインパターンは???🤔🤔🤔

f:id:mizdra:20171210234047p:plain
証拠です

…申し訳ありませんが今回はテーマを変えて記事を書いています🙇 本当はデザインパターンの話を書こうと思っていたのですが記事に出来るほど知見が溜まっていなかったのでボツ 🚮 となりました. デザインパターンについてはまたいつか別の機会に話そうと思います 🙏

…さて話を戻します. なぜScalaを使って乱数ツールを書くかというと, Scalaには非常に充実したコレクションライブラリが備わっており, これを用いることで乱数列に対する複雑な操作を簡単に記述できるからです*1. 乱数ツールを記述していく中で, このコレクションライブラリがいかに力を発揮していくかを感じでもらえればと思います.

前提

Iteratorを継承したLCGを作成する

まずは乱数生成器(LCG)を作成しましょう. LCGクラスにIteratorトレイトを継承させることで, 乱数列をコレクションとして扱うことができます. そうすることで, コレクションライブラリで提供される非常に多くの便利なメソッドがLCGクラスで利用できるようになります.

class LCG(seed: Int, a: Int, b: Int) extends Iterator[Int] {
  var state: Int = seed

  override def hasNext: Boolean = true
  override def next(): Int = {
    state = state * a + b
    state
  }
  def next(n: Int): Int = java.lang.Integer.remainderUnsigned(this.next(), n)
}

object Wandbox {
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 0x41c64e6d, 0x6073)
    println(lcg.take(5).map(_.toHexString).toList)
    // stdout: List(6073, e97e7b6a, 52713895, 31b0dde4, 8e425287)
  }
}

サンプルコードの例ではコレクションのメソッド take(), map(), toList() を使用して先頭5つの乱数を16進数表記で出力しています.

調律された乱数列を取得する

通常ではLCGで得られる生の乱数列は乱数性に問題があるため, 下半分のbitを切り落として利用されます. これを先程作成したLCGクラスを用いて書くと以下のようになります.

object Wandbox {
  def temper(state: Int): Int = state >>> 16
  
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 0x41c64e6d, 0x6073)
    val temperedLcg = (new LCG(0x00000000, 0x41c64e6d, 0x6073)).map(temper(_))
    
    println(lcg.take(5).map(_.toHexString).toList)
    // stdout: List(6073, e97e7b6a, 52713895, 31b0dde4, 8e425287)
    println(temperedLcg.take(5).map(_.toHexString).toList)
    // stdout: List(0, e97e, 5271, 31b0, 8e42)
  }
}

写像を表わす map() メソッドを用いて生の乱数を調律後の値へと変換しています. たったこれだけで, 生の乱数列を調律された乱数列に変換できます.

ただし, これでは map() によって返ってくる型が Iterator[Int] となってしまい, LCGクラスで実装したメソッド (def next(n: Int): Int など) を呼び出せなくなってしまいます. これは後々困ったことになるのでここではLCGクラスを継承して調律された乱数列を生成するTemperedLCGクラスを作成することにしましょう.

class TemperedLCG(seed: Int, a: Int, b: Int) extends LCG(seed, a, b) {
  override def next(): Int = super.next() >>> 16
}

object Wandbox {
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 0x41c64e6d, 0x6073)
    val temperedLcg = new TemperedLCG(0x00000000, 0x41c64e6d, 0x6073)
    
    println(lcg.take(5).map(_.toHexString).toList)
    // stdout: List(6073, e97e7b6a, 52713895, 31b0dde4, 8e425287)
    println(temperedLcg.take(5).map(_.toHexString).toList)
    // stdout: List(0, e97e, 5271, 31b0, 8e42)
  }
}

fork() メソッドを実装する

LCGインスタンスはmutableな操作をするので, そのまま複数のスレッドに渡すと破滅します. 次は乱数列上の5つの乱数を出力する処理を, 1消費ずつずらしながら各々のスレッドで実行する例です*2.

// 破滅する例
object Wandbox {
  def printRands(lcg: LCG, n: Int) = println(for (i <- 1 to n) yield lcg.next())
  
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 1, 1)
    
    (1 to 3).map(_ => {
      val f = Future {
        printRands(lcg, 5)
      }
      lcg.drop(1) // 1つずらす
      f
    }).foreach(Await.ready(_, Duration.Inf))
    // stdout:
    // Vector(4, 5, 6, 7, 8)
    // Vector(9, 10, 11, 12, 13)
    // Vector(14, 15, 16, 17, 18)
    
    // 本当は次のようになって欲しい(行については順不同):
    // Vector(1, 2, 3, 4, 5)
    // Vector(2, 3, 4, 5, 6)
    // Vector(3, 4, 5, 6, 7)
  }
}

そこで自身のクローンを作成する fork() メソッドを実装してクローンをスレッドに渡すようにしてみましょう.

import scala.concurrent._
import ExecutionContext.Implicits.global
import scala.concurrent.duration.Duration

class LCG(seed: Int, a: Int, b: Int) extends Iterator[Int] {
  // ...
  def fork(): LCG = new LCG(state, a, b)
}

class TemperedLCG(seed: Int, a: Int, b: Int) extends LCG(seed, a, b) {
  // ...
  override def fork(): TemperedLCG = new TemperedLCG(state, a, b)
}

// 破滅しない例
object Wandbox {
  def printRands(lcg: LCG, n: Int) = println(for (i <- 1 to n) yield lcg.next())
  
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 1, 1)
    
    (1 to 3).map(_ => {
      val forkedLcg = lcg.fork()
      val f = Future {
        printRands(forkedLcg, 5)
      }
      lcg.drop(1) // 1つずらす
      f
    }).foreach(Await.ready(_, Duration.Inf))
    // stdout:
    // Vector(1, 2, 3, 4, 5)
    // Vector(3, 4, 5, 6, 7)
    // Vector(2, 3, 4, 5, 6)
  }
}

これで並列処理でも安全にLCGインスタンスを扱うことができます.

作成したLCGをエンカウント処理で使う

ここまで作成したLCGクラスを使って3世代の野生乱数の処理を書いてみましょう. 問題を簡単にするために次のような仕様で処理を書くことにします.

  • seedは 0x00000000
  • 検索する消費数の範囲は 1 〜 100000
  • CDSの個体値がVのものを検索
  • 並列処理する

…と簡単にしたと言ってもなかなか複雑そうなプログラムになりそうですが, ここでもScalaのコレクションライブラリの力が発揮されます. コードを見てみましょう*3.

class LCG(seed: Int, a: Int, b: Int) extends Iterator[Int] { ... }

class TemperedLCG(seed: Int, a: Int, b: Int) extends LCG(seed, a, b) { ... }

// エンカウントデータ
case class Encounter(frame: Int, slot: Int, level: Int, nature: Int, pid: Int, ivs: Seq[Int], item: Int) {
  override def toString(): String = {
    s"frame: $frame, slot: $slot, level: $level, nature: $nature, pid: ${pid.toHexString}, ivs: ${ivs.mkString("(", ", ", ")")}, item: $item"
  }
}

// エンカウントデータを生成するBuilder
class EncounterBuilder(wantedIvs: Seq[Range]) {
  var frame: Int = 0
  var slot: Int = 0
  var level: Int = 0
  var nature: Int = 0
  var pid: Int = 0
  var ivs: Seq[Int] = Nil
  var item: Int = 0
  def frame(frame: Int): Unit = this.frame = frame
  def slot(slot: Int): Unit = this.slot = slot
  def level(level: Int): Unit = this.level = level
  def nature(nature: Int): Unit = this.nature = nature
  def pid(pid: Int): Unit = this.pid = pid
  def ivs(ivs: Seq[Int]): Unit = this.ivs = ivs
  def item(item: Int): Unit = this.item = item

  def result(): Option[Encounter] = {
    // 個体値が条件を満たしていなければ None を返す
    val isValidIvs = (wantedIvs zip ivs).forall({ case (range, iv) => range.contains(iv) })
    if (isValidIvs) Some(Encounter(frame, slot, level, nature, pid, ivs, item))
    else None
  }
}

object Wandbox {
  def printRands(lcg: LCG, n: Int) = println(for (i <- 1 to n) yield lcg.next())
  def getPID(lid: Int, hid: Int): Int = lid.toInt | (hid.toInt << 16)
  def isValidPID(pid: Int, nature: Int): Boolean = java.lang.Integer.remainderUnsigned(pid, 25) == nature
  def getIVs(rand: Int): (Int, Int, Int) = ((rand >> 10) & 0x1F, (rand >> 5) & 0x1F, rand & 0x1F)
  def searchEncounter(lcg: LCG, builder: EncounterBuilder, frame: Int) = {
    builder.frame(frame)
    
    // 出現スロット, レベル, 性格の決定
    builder.slot(lcg.next(100))
    builder.level(lcg.next())
    val nature = lcg.next(25)
    builder.nature(nature)
    
    // (pid % 25) == nature となるような PID を探す
    val pid: Int = lcg
      .grouped(2) // List(LID, HID) の組にする
      .map(iter => getPID(iter.head, iter.tail.head))
      .find(pid => isValidPID(pid, nature))
      .get // 必ずPIDが見つかることが保証されているので getOrElse の代わりに get を使っている
    builder.pid(pid)
    
    // 個体値決定
    val (b, a, h) = getIVs(lcg.next())
    val (d, c, s) = getIVs(lcg.next())
    builder.ivs(Vector(h, a, b, c, d, s))
    
    lcg.drop(5) // 5つの乱数をスキップ
    
    // 持ち物の決定
    builder.item(lcg.next(100))
    
    // 個体が条件を満たしていれば Some(Encounter), そうでなければ None が返る
    builder.result()
  }
  
  def main(args: Array[String]): Unit = {
    val lcg = new TemperedLCG(0x00000000, 0x41c64e6d, 0x6073)
    val wantedIvs = Vector( // 検索する個体値の範囲
      0 to 31,  // h
      0 to 31,  // a
      0 to 31, // b
      31 to 31, // c
      31 to 31, // d
      31 to 31  // s
    )
    val maxFrame = 100000
    
    val results: Seq[Encounter] = (1 to maxFrame).map(frame => {
      val forkedLcg = lcg.fork()
      val builder = new EncounterBuilder(wantedIvs)
      val f: Future[Option[Encounter]] = Future {
        searchEncounter(forkedLcg, builder, frame)
      }
      lcg.drop(1) // 1つずらす
      f
    }).map(Await.result(_, Duration.Inf)).flatten
    
    results.foreach(println _)
  }
}

出力

frame: 15506, slot: 40, level: 7292, nature: 0, pid: d000755f, ivs: (6, 27, 31, 31, 31, 31), item: 38
frame: 15530, slot: 86, level: 27437, nature: 0, pid: d000755f, ivs: (6, 27, 31, 31, 31, 31), item: 38
frame: 15540, slot: 72, level: 47192, nature: 0, pid: d000755f, ivs: (6, 27, 31, 31, 31, 31), item: 38
frame: 31693, slot: 21, level: 43592, nature: 1, pid: 44b35699, ivs: (2, 19, 18, 31, 31, 31), item: 64
frame: 36963, slot: 73, level: 6376, nature: 9, pid: 8e20683d, ivs: (13, 7, 24, 31, 31, 31), item: 43
frame: 36983, slot: 85, level: 41274, nature: 9, pid: 8e20683d, ivs: (13, 7, 24, 31, 31, 31), item: 43
frame: 36993, slot: 90, level: 26931, nature: 9, pid: 8e20683d, ivs: (13, 7, 24, 31, 31, 31), item: 43

乱数列からエンカウントデータを生成する searchEncounter() では, Builderパターンを用いています*4. PIDの決定では grouped() メソッドを使い, コレクションを (LID, HID) の組にすることで2つずつ乱数を消費しながら条件を満たすPIDの検索をしています.

Builderに注目すると, Builder自身に検索する個体の条件を持たせていることがわかります. これは result() で使用していて, 条件を満たさなければエンカウントデータの代わりに条件を満たす個体が見つからなかったことを表わす None を返しています. また条件が満たされているかの判定には zip, forall を使っています. Builderパターンを採用することで個体の生成, および個体のフィルタリング処理が searchEncounter() からBuilderに切り離されることになります.

このように, Scalaのコレクションライブラリの力を借りることで乱数ツールのコードを綺麗に記述することができます 👍

おわりに

以上が Pokémon RNG Advent Calendar 2017 10日目「Scalaで乱数ツールを書く話」となります. いかがでしたでしょうか. この記事を読んでちょっとしたツールの作成でも綺麗なコードを意識して取り組むきっかけになれればと思います 😃

adventar.org

11日目は oupo さんの担当です!

参考

*1:他にも「型システムが強力だから」, 「関数型言語であるから」などの理由があります.

*2:この後すぐに出てきますが, このようなテクニックは乱数ツールの中でよく使われています.

*3:何の関係もない話ですが最初にこのコードをwandboxで書き上げた時にタブがフリーズし, 1時間の成果が水の泡になる出来事が発生しました

*4:申し訳程度のデザインパターン要素です

Emtimerの紹介

はじめに

この記事はPokémon RNG Advent Calendar 2017 一日目の記事です 🎉🎉🎉

adventar.org

今年もPokémon RNG Advent Calendarの季節がやって来ました! ポケモン最新作も発売したことですし, 皆さんの乱数調整への意気込みもいっそう高まっていると思います. 今回は皆さんの乱数調整を支援するためのツールを開発したのでその紹介をします.

Emtimerの紹介

乱数調整では, 事象を発生させるタイミングを合わせるためにカウントダウンタイマーが頻繁に使われます. 既に乱数調整のためのタイマーはいくつか公開されていますが, 多くは次のような問題点を含んでいます.

  • Adobe Flashへの依存
    • 今後数年を掛けてAdobe Flashはサポート終了される予定*1であり, Adobe Flashに依存するツールを利用するのは不適切です
    • メジャーなモバイル向けブラウザではAdobe Flashに対応していないものが殆どです
  • モバイルフレンドリーでない
    • スマートフォンタブレットといった, 画面の小さな端末のことを考慮して設計されておらず, 画面の乱れや操作への支障が生じます
    • モバイル端末を利用するユーザが急増する今, モバイル対応はとても重要です
  • マルチプラットフォーム非対応
    • Adobe Flash や特定のOSに依存するツールは様々な環境で動作させることを考慮していません
    • 例えば, Adobe Flashに依存しているツールはAdobe Flashをサポートしていない環境では動作せず, Windows向けに開発されたツールは MacOS 上では動作しません
  • 新機能の導入が困難
    • 多くのツールのソースコードは公開されておらず, ユーザが新機能を開発して追加することは困難です

これらの問題を解決するために, 新たにEmtimerというツールを開発しました.

emtimer.mizdra.net

Emtimerは乱数調整のために開発されたシンプルで扱いやすい高機能なカウントダウンタイマーです. Emtimerには次のような特徴があります.

機能の紹介

Emtimerは乱数調整で最も使われているポケモンの館のエメループ *2を参考に作られました. Emtimerにはエメループから引き継いだ機能と, より乱数調整を快適にするために追加された新機能があります.

  • エメループから引き継いだ機能
    • キーバインド
    • サウンド
    • 開始までの猶予
    • 切り上げ
    • 残り時間特大表示
  • 新機能
    • ループ
    • モバイルに最適化されたコントローラ
    • カウントダウン時間の単位切り替え
    • ハイライト
    • Progressive Web Apps

エメループから引き継いだ機能

キーバインド

カウントダウンの開始/停止をスペースキーで操作することができます. スペースキー押下で停止, 押上で開始です. この機能により簡単にタイマーを再起動できます.

サウンド

秒の桁が更新される時に「ピッ」という音を鳴らします. サウンドを有効化するか無効化するか, カウントダウン終了の何秒前から音を鳴らすかを設定できます. タイミングを合わせたいときに便利です.

開始までの猶予

「待機時間」のカウントダウンを開始する前に別途カウントダウンを設けます. 両手が塞がっていてカウントダウン開始のボタンが押せない時に便利です.

切り上げ

値を設定すると, 「待機時間」のカウントダウンを指定秒数だけ早く切り上げることができます. 甘い香りを使って乱数調整する際は甘い香りを使ったときに発生する鳴き声の長さがポケモンによって異なるので, その調整に利用すると便利でしょう.

残り時間特大表示

エメタイマーBIGから引き継いだ機能です. 残り時間の秒未満の値を赤丸の位置で表現します. 赤丸が中央に来たときが秒の桁が更新される瞬間です.

f:id:mizdra:20171130221504p:plain:w600
残り時間表示. カウントダウン中は1秒ごとに赤丸が左から右へと流れていく.

新機能

ループ

タイマーの1サイクル (開始までの猶予のカウントダウン+待機時間のカウントダウン) をループします. ループ回数を直接指定, もしくは無限ループできます. 一定間隔でタイマーを起動したい時に便利です.

モバイルに最適化されたコントローラ

再生/一時停止/再開/停止ボタンを搭載したコントローラを設置しました. モバイルでも扱いやすいよう画面下部に固定し, ボタンを大きくしています.

f:id:mizdra:20171130221641p:plain:w300
画面下にコントローラが表示される

カウントダウン時間の単位切り替え

待機時間, 開始までの猶予, 切り上げの値の単位を「秒」および「フレーム」から選択できます.

ポケモンの館のエメループにもフレームを秒に変換するツールが結合されていますが, 実際のカウントダウンで使われる値の単位は「秒」です. これはカウントダウンしたい時間を「秒」と「フレーム」どちらを基準にしたのかの判断が困難になるという問題があります.

本ツールではカウントダウンしたい時間をどちらの単位の値として扱うかを直接していする設計にすることで, どの単位を基準にしたのかを判断しやすくしています. 多くの場合, こちらのほうが直感的でしょう.

f:id:mizdra:20171130221556p:plain:w500
それぞれの入力欄に異なる単位でカウントダウン時間を入力できる

ハイライト

秒の桁が更新される時に「残り時間特大表示」の枠内がハイライト (発光) します. タイミングを合わせたいときに便利です.

youtu.be

Progressive Web Apps

これはほんの先程追加した新機能です! Progressive Web Apps (PWA) とは簡単に言うと, 「モバイル向けWebアプリを (Google PlayApp Storeでインストールするような) ネイティブアプリに近づける」技術です. WebアプリをPWAに対応させると様々な便利な機能が有効になりますが, ここでは本ツールで活用されているほんの一部の機能を紹介します.

(残念ながらPWAはSafariなどの一部のブラウザには対応していません😢 今後SafariでもPWAの対応が進んでいくと思われますが*3, 今すぐにこの機能を利用したい場合は Google Chrome の使用を推奨します👍)

ウェブアプリ マニフェスト

ウェブアプリ マニフェストはアプリを(スマートフォンタブレット, パソコンなどの)ホーム画面に追加したり, ホーム画面からアプリを起動した時の外観をカスタマイズする機能です.

youtu.be

動画では次の機能を確認できます.

  • ホーム画面にWebアプリを追加する
  • ホーム画面からアプリをタップすると初めにスプラッシュ画面が表示され, ロードが終わってからアプリ画面が表示される
  • ブラウザのUI (アドレスバーなど) が隠されている
  • ブラウザとは別のウインドウとして扱われている

キャッシング

ツールを構成するコンテンツは全てローカルにキャッシュされ, 初回以降のアクセスが高速になります. コンテンツの取得はキャッシュを優先的に使用し, 必要なコンテンツだけがネットワークから取得されます.

オフラインで動作可能

なんとEmtimerはWebアプリにも関わらず, オフラインでも動作します!!! これは先程紹介したキャッシングを用いて, オフライン時は全てキャッシュからコンテンツを取得することで実現しています. お使いの端末が機内モードであっても動作します. 飛行機の中でも乱数調整し放題です 💪💪💪

youtu.be

自動更新

PWAは一見するとネイティブアプリのようですが, 実際にはWebアプリであり普通のWebアプリと同じようにアクセスする度にコンテンツは更新されます. ユーザは更新ボタンを押さずに, ツールにアクセスするだけで新機能を利用することができます.

新機能/改善のリクエストについて

ここまで本ツールに搭載されている機能を紹介してきました. もしかしたら🔥熱心な乱数勢🔥*4の方々は「あの機能が欲しいのに無い…!」, 「UIを改善して欲しい!」などと思っているかもしれません. そういった場合は思っていることを開発者に伝えて (フィードバックして) 頂けると今後の開発の支援になります. フィードバックはこの記事のコメントや, 開発者 @mizdra へのリプライ, またはGitHubリポジトリのIssueまでお気軽にどうぞ 😃

今後の開発の予定

Emtimerの開発は🚀今現在も進行中🚀です. 今後の開発の予定はまだ不透明な部分が多いですが, ちょっとだけ紹介します.

  • UI/UXの改善
    • モバイルでより使いやすいようUI/UXを改善します
  • カウントダウン処理の改善
    • カウントダウンがより正確に行われるよう改善します
  • 新しいタイマーの追加
    • 今あるSimpleTimerとは別に, 新しいタイマーを追加します
    • 8秒間のカウントダウン -> 16秒間のカウントダウン -> 17秒間のカウントダウン のように, 異なるカウントダウン時間のタイマーを組み合わせられるプログラマブルなタイマーを追加予定です

おわりに

以上が Pokémon RNG Advent Calendar 2017 1日目「Emtimerの紹介」となります. ここまで読んでくださった方々, ありがとうございました! 😄

それでは今日からクリスマスまでの間, Pokémon RNG Advent Calendar 2017 を楽しんでいきましょう! 良いクリスマスを!

adventar.org

2日目は @Blastoise_X さんの担当です!

参考

*1:http://blogs.adobe.com/japan-conversations/201707adobe-flash-update/

*2:一般的にエメタイマーと呼ばれています

*3:PWAの基礎となっているService WorkerがSafariに実装されることが決まっている (参考: http://trac.webkit.org/changeset/220220/webkit/trunk/Source/WebCore/features.json)

*4:乱数調整に携わる人々のことをそう呼びます

Headless Chrome を使って自動車学校の技能教習の予約が空いたら通知するスクリプトを書いた

最近, 免許を取るために自動車学校に通っていたのですが, 技能講習の予約が一杯で中々教習が進まず困っていました. 通っていた教習所ではオンラインで技能講習の予約を取れるサービスが公開されていたので, また最近話題の Headless Chrome を触ってみたいとも思っていたので, 勢いでスクレイピングして自動車学校の技能教習の予約が空いたら通知するスクリプトを書いてみました.

この記事ではどのようにコードを書いたのか (主に GoogleChrome/puppeteer 周り) を知見を含めて紹介します.

スクレイピング

Headless ChromeAPI を wrap したライブラリである GoogleChrome/puppeteer を使ってスクレイピングしました.

スクレイピングする対象のサービスについて説明しておくと, トップのログインフォームでform認証するとメニューに飛び, そこから技能教習予約ページと予約キャンセルページに飛べる仕様となっています.

# 例
reservation.car.com ... formによるログイン認証ページ
  /menu             ... メニュー
  /reservation      ... 技能教習予約ページ
  /cancel           ... 予約キャンセルページ

セットアップ

スクレイピングを始める前に, まずはpuppeteerのセットアップをします.

const setup = async () => {
  const browser = await puppeteer.launch()    // HeadlessモードでChromeを起動
  const page    = await browser.newPage()     // 新しいタブを開く
  page.setViewport({width: 900, height: 800}) // Viewportの大きさを指定
  return {browser, page}
}

const scrape = async () => {
  const {browser, page} = await setup()
  
  // スクレイピング

  await browser.close() // Chromeを終了する
}

// 開発時は unhandledRejection を subscribe する
process.on('unhandledRejection', (e) => console.log(e))

scrape()

puppeteer のAPIの多くは Promise を返すので async/await を使うと楽に書けます. また, puppeteer内部で発生した unhandledRejection はそのままだとエラーの詳細が出力されないため, 開発時は subscribe しておくと良いでしょう.*1

ちなみに puppeteer.launch() にオプションを渡すと Headless モードをオフにして Chrome を起動したり, ブラウザの操作を一定間隔空けて実行することができます. これによって実際の挙動を画面で, 目で追いやすい速度で確認することができます.

const browser = await puppeteer.launch({
    headless: false, // 画面を表示
    slowMo: 500      // 500ms間隔でブラウザを操作
})

f:id:mizdra:20171001193642g:plain

form認証

page.$eval でクエリにマッチした要素をコールバック関数で受け取ることができるので, これを使ってフォームの値を書き換えます. 値を入力し終えたら page.click を用いて送信ボタンをクリックし, page.waitForNavigation で遷移後のページで load イベントが発火するまで待機します.

const login = async (page) => {
  // 予約サービスに移動
  await page.goto('http://reservation.car.com')

  // Node を取得し, フォームの値を書き換える
  await page.$eval('input[name="username"]', (el) => { el.value = 'mizdra' })
  await page.$eval('input[name="pass"]', (el) => { el.value = 'password' })

  // submit ボタンをクリック
  await page.click('input[type="submit"]')

  // ページの遷移が完了するまで待機
  await page.waitForNavigation({waitUntil: 'load'})
}

予約に空きのある時間帯の取得

page.evaluate は引数で渡した関数をブラウザ上で実行するため, DOM APIにアクセスすることができます. 以下では page.evaluate を使って予約に空きのある時間帯を取得しつつ, 予約ページのスクリーンショットを作成しています.

const screenshot = async (page) => {
  // ページの読み込みが終わるまで待機
  await page.waitForNavigation({waitUntil: 'load'})
  await page.screenshot({path: `screenshots/${Date.now()}.png`})
}

const getFreeClassList = async (page, classList) => {
  const freeClassList = await page.evaluate(() => {
    const buttons = Array.from(document.querySelectorAll('button.free'))
    // value プロパティから日付 (ex. '2017/09/28 16時') を取り出す
    return buttons.map(button => button.value)
  })
  return freeClassList
}

const filterClassListByFree = async (page) => {
  await gotoReservation() // 技能教習予約ページに移動
  await screenshot(page)  // 予約状況をスクリーンショットする

  // 予約に空きのあるクラスのリストを取得
  const freeClassList = await getFreeClassList(page, classList)

  await gotoMenu(page) // メニューに戻る

  // 予約に空きのあるクラスのみを返す
  return classList.filter(cls => freeClassList.includes(cls))
}

ここで注意ですが, デバッグのために page.evaluate に渡した関数の中で console.log を呼び出してもログはブラウザのコンソールに表示されるだけで, Node.js のコンソールには何も出力されません.*2

await page.evaluate(() => {
   // ブラウザのコンソールには出力されるが, Node.js のコンソールには出力されない
  console.log('Hello world!')
})

通常ブラウザのコンソールにログを出力することは無いので次のように console イベントを subscribe して警告を出すようにしておくと良いでしょう.

page.on('console', (...args) => {
  console.warn('Warning: Console API methods is called in browser context.')  
  console.log(...args)
})
await page.evaluate(() => {
  // ブラウザと Node.js の両方のコンソールに出力
  console.log('Hello world!')
})

通知

取得した時間帯を nodemailer/nodemailer を使ってメールで送信します. 今回はメールアカウントに Gmail を使い, SMTP で送信します.

import nodemailer from 'nodemailer'

// 本文のレンダリング関数
const renderHTML = (freeClassList) => `
<p>次の時間帯の予約が空きました. <a href="http://reservation.car.com">ここをクリック</a>して予約を完了して下さい.</p>
<ul>${freeClassList.map(cls => `<li>${cls}</li>`).join('')}</ul>
`

// 予約の空いたクラスをメールで通知する
const sendMail = (freeClassList) => {
  const transporter = nodemailer.createTransport({
    host: 'smtp.gmail.com',
    port: 465,
    secure: true,
    auth: {
      user: '<Gmail アドレス>',
      pass: '<Google アカウントのパスワード>'
    }
  })
  transporter.sendMail({
    from: '"free-driving-class-notification-bot"',
    to: '<通知先のメールアドレス>',
    subject: '技能教習の予約が空きました',
    html: renderHTML(freeClassList)
  }, (err) => {
    if (err) return console.log(err)
  })
}

予約が開くと, 以下のようにメールが飛んできます. これにて完成です 🎉🎉🎉

f:id:mizdra:20171001184759j:plain

その他: 遭遇した問題

puppeteer を弄っているといくつか怪しい挙動を見つけたのでIssue&PRを出しました.

おわりに

*1:http://yosuke-furukawa.hatenablog.com/entry/2016/07/12/103734

*2:これのせいでn時間吸われた

*3:これのせいでn時間吸われた

Dockerを学んだ際の備忘録

概要

お盆の間にDockerについて勉強した序にDockerをどう学んでいったか (どの記事を読んだか, どういう流れで学んだか) を軽く纏めておきます.

目標

以前作ったWebアプリケーション (タイマー) をDockerに載せる.

学習の流れ

実際にDockerizeしてみる

DockerでのNodeアプリ構築で学んだこと | インフラ・ミドルウェア | POSTD で紹介されている手法をベースにDockerizeしてみました.

コンテナの起動

$ git clone https://github.com/mizdra/emtimer.git
$ git checkout 41d2e3e

# imagesのビルド
$ docker-compose build

# プロダクション用
## ソースコードをプロダクション向けにビルドしてhttp-serverでserveする
$ docker-compose -f docker-compose.prod.yml up

# 開発用
## webpack-dev-serverが立ち上がる
## ファイルの変更を検知したら再ビルド&自動リロードされる
$ docker-compose -f docker-compose.yml up

とりあえずやってみましたが設定ファイルを書く際に考えることが多いかなと感じました. まあでもキャッシュを活用しようとすると少し複雑になるのは仕方なさそう. ちょろっとDockerizeするだけならもっと設定ファイルをシンプルにしても良いかもしれません. あと気になったのは COPYUSER の影響を受けないので別途 RUN chown -R app:app $HOME/* する必要があるところ. ただ, この問題については既にPRが立っているのでその内改善されるかも.

ちなみにコンテナ起動時に CMD ["yarn", "run", "prod:start"] の代わりにshellを差し込めば対話的に好きなように作業できます. 便利.

# shellを差し込んで起動
$ docker-compose -f docker-compose.yml run emtimer bash

# 試しにpackageを追加してみる
app@XXXX:~/emtimer$ yarn add moment

# 追加されていることを確認
app@XXXX:~/emtimer$ ls -1 node_modules | grep moment
moment

app@XXXX:~/emtimer$ exit

# imageのnode_modulesはvolumeなのでホストOS上からは見えない
$ ls -1 node_modules | grep moment

# package.jsonやyarn.lockはbind mountsの機能により更新されているので
# yarn installで追加されたパッケージをインストールできる
$ yarn install
$ ls -1 node_modules | grep moment
moment

多くの場合, 開発時はdocker上で開発サーバを立ててホストOS上からエディタでソースコードを編集するような形を取ることになります. その際にホストOS上に node_modules の中身が存在しないとエディタのプラグイン(eslintなど)がエラーを吐くので, 適時ホストOS上でも yarn install すると良いと思います. 多分… *1

おわりに

ヨッシャDockerやるぞという気持ちが湧いてきたので1からDocker学んでみました. 2日くらい掛かりましたがそれなりに勉強になったので満足度高めでした.

おわり.

*1:知見が殆どないのでこれで合っているか分からず… もっと良い解決法あれば教えて頂けると :pray:

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.