undefined

bokuweb.me

100行で書けるブラウザで動作するyoutube音ゲーの作り方

はじめに

【WEBサービス】youtubeを使った音ゲー×タッチタイピングサービスを作ってみた【つくってみた】 - ぼくのかんがえたさいきょうのうぇぶさーびす

上記の記事で書いた「typebeats」のゲーム部を簡素化して説明してみます。 削っていくと100行に入りそうだったので詰め込んでみました。 かなり簡素化していますが、基本的な考え方は共通です。

ゲーム概要

リズムに合わせて降ってくるアイコンをタッチ/クリックするゲームです。 画面をタッチ/クリックするとゲームが開始します。

こいつが

f:id:bokuweb:20150117133504p:plain

こいつに

f:id:bokuweb:20150117133522p:plain

重なるくらいがタッチ/クリックするタイミングです。

スマホでの動作は確認していません。たぶん動かない気がします。

言語はCoffeeScript、フレームワークはenchant.jsです。 触れたことのない方はどっとインストールも参考にしてください。

CoffeeScript入門 (全13回) - プログラミングならドットインストール

enchant.js入門 (全12回) - プログラミングならドットインストール

リポジトリとデモ

リポジトリ

bokuweb/youtube_music_game · GitHub

デモ

http://bokuweb.github.io/youtube_music_game/

準備するもの

今回必要なものは以下のようになります。

  • ブラウザ(chromeを推奨)
  • エディタ(CoffeeScriptのシンタックスハイライトができるものがおすすめ)

用語

項目 説明
ノート リズムに合わせて落下してくるオブジェクト
ラベル 文字を表示する部品
シーン タイトル画面、ゲーム画面、ポーズ画面、ゲームオーバー画面など、「○○画面」と呼ばれる単位 *1
FPS Frame Per Sec 1秒間に何回画面が更新されるか

*1 http://www.atmarkit.co.jp/ait/articles/1304/01/news034.htmlより ここではゲーム画面しかないのでシーンと言ったらゲーム画面のことです。

ゲーム詳細

ノートがタッチ/クリックされるべきタイミングを_timingに格納しておき、この時間から逆算して、ノートをシーンに追加したり、動かしたりさせています。 enchant.jsの機能で指定したFPSの周期で動作させるメソッドを登録できますので、この登録したメソッド内で何を行うべきなのか判定し、実行するという形になります。

ゲームクラス定義部

ここではclass内で共通して参照する変数などを定義しておきます。

class @Game      # ゲームクラスを宣言します。
                 # class名に@を付けることでglobalスコープに配置され、別ファイルから参照することができるようになります。
  YOUTUBE_ID = 'HNYkOJ-T63k' # 再生する動画のIDを設定します。
  _game = null   # echant.jsのゲームコアを格納しておくprivate変数です。
  _yt = null     # youtube API制御classのインスタンスを格納しておく変数です。
  _judge = null  # タイミング判定を表示するラベルを格納しておく変数です。

  # 落下してくるオブジェクトのタッチ/クリックタイミングを定義します。単位は[sec]です。
  # ここに定義してある時間と実際にタッチ/クリックした時間を比較し、「GREAT」、「BAD」などの判定を行います。
  _timing = [6.14,7.486,8.155,9.977,10.377,11.611,12.062,12.765,13.583,13.945,14.223,14.707,15.059,16.241,16.577,17.425,20.186,20.917,21.593,22.313,22.449,23.123,24.297,24.965,25.113,25.464,26.148,26.635,27.294,28.103,30.910,31.601,32.305,33.024,34.054,34.786,35.360,36.140,37.028,38.402,38.829,39.129,40.354,41.051,41.553,42.233,43.043,43.729,44.261,45.705,46.448,47.416,48.407,50.158,51.310,52.363,53.031,54.417,55.288,55.640,56.472,57.190,58.110,59.095,59.648,60.776, 61.993, 62.370, 63.072, 63.808, 64.493, 65.111, 65.688, 66.414, 67.192, 68.891, 69.209, 69.918, 70.056, 71.111, 71.744, 72.428, 72.861, 73.263, 73.755, 74.099, 74.639, 75.090, 75.426, 75.941, 76.472, 76.992]
  _timingIndex = 0  # _timing配列用のindexです
  _status = "stop"  # ゲームのステータスです
  _endTime = 80     # ゲームの終了時間を定義します。
                    # ここでは再生時間が80secを超えたら終了処理に入ります。

コンストラクタ

クラスからnewでオブジェクトを作成した際に、自動的に実行されます。 このサンプルでは一番最後の行でnewしていますのでその時に実行されます。 constructor内では以下の処理を行っています。

  • ゲーム本体の生成
  • FPSの設定
  • 事前にロードするリソースの設定
  • ゲームの開始
  constructor : (parms)->
    enchant()                               # enchant.jsを使うためのおまじないです
    _game = new Core(800, 600)              # ゲーム本体を生成します。Coreの引数はゲームのwidthとheightです。
                                            # ここでは800×600のゲームを生成しています。
    _game.fps = 30                          # frame per sec つまり1秒当たり何回描画するかを設定します。
    _game.preload("icon.png", "shadow.png") # 使用する画像ファイルをpreloadするよう設定しておきます。
    _game.start()                           # ゲームを開始します。
                                            # ただ、いきなりゲームが開始されるわけではなくまずは画像のロードが行われます。
                                            # ロードが完了すると_game.onloadメソッドが呼ばれます。

ロード完了時の処理

ロードが完了すると_game.onloadメソッドが呼ばれます。 onloadメソッド内では以下の処理を行っています。

  • ゲームタッチ/クリック時の処理を登録
  • 動画の埋め込み
  • 判定ラベルの追加
  • 着地ポイントのs追加
    _game.onload = ->                       # 画像等のロードが完了すると実行されるメソッドです

      # ゲーム画面をタッチ/クリックしたときの処理を登録しておきます。
      _game.rootScene.addEventListener "touchstart", (e)->
        if _yt.isReady()  # youtubeプレイヤーが準備できている?
          _game.rootScene.addEventListener "enterframe", _proccesRootSceneFrame # 30FPSすなわち1/30 = 33.3333..ms周期で呼び出されるメソッドを登録しておきます。
                                                                                # 33.3333..ms経過するたびに_proccesRootSceneFrameメソッドがコールされます。
          _status = "playing"   # ステータスをplay中に変更
          _yt.play()            # youtube動画を再生します。

      # 動画を埋め込みます
      video = new Entity()
      video._element = document.createElement('div')
      video.x = 500  # 動画設X置座標
      video.y = 300  # 動画設置Y座標
      # iframeタグを埋め込みます。
      # https://www.youtube.com/embed/HNYkOJ-T63k?enablejsapi=1..のHNYkOJ-T63kが動画IDです。
      # ここを変更することで動画を変更できます。
      # その他詳細はhttps://developers.google.com/youtube/js_api_reference?hl=jaを参照してください。
      video._element.innerHTML = '<iframe src="https://www.youtube.com/embed/'+YOUTUBE_ID+'?enablejsapi=1&controls=0&showinfo=0&autoplay=0&rel=0&vq=small"  width="300" height="200" frameborder="0" id="player"></iframe>'
      _game.rootScene.addChild(video)  # 動画をシーンに追加

      _yt = new Yt() # youube制御インスタンスを生成

      # 「GREAT」などの判定結果を表示するラベルを生成
      _judge = new Label()
      _judge.font = "36px Arial"       # サイズ、フォントを指定
      _judge.x = 100                   # X座標
      _judge.y = 100                   # Y座標
      _game.rootScene.addChild(_judge) # シーンに追加

      # 落下してくるオブジェクトの着地ポイントを示すイメージを設置する
      shadow = new Sprite(80, 80)
      shadow.image = _game.assets["shadow.png"] # 画像ファイルを指定
      shadow.x = 100                            # X座標
      shadow.y = 380                            # Y座標
      _game.rootScene.addChild(shadow)          # シーンに追加

ノート生成部

_isNoteGenerateTiming はノートを作成するかどうか問い合わせるメソッドです。 返り値がtrueの場合ノートを生成するタイミング、falseの場合は生成するタイミングではないことを示します。 動画の再生時間がタッチ/クリックタイミングから1秒減算した値より大きければtrueを返します。 すなわちタッチ/クリックタイミングから逆算して約1秒前にノートを生成しています。

  _isNoteGenerateTiming = ->
    if _timing[_timingIndex]? # 配列_timingにデータが存在するかしらべます。
      if _yt.getCurrentTime() > _timing[_timingIndex] - 1 # タッチ/クリックタイミングの1秒前?
        return true
    return false

_generateNote は引数で指定された番号のノートを作成し、ノートの動作設定、33.3333..ms周期(すなわち30FPS)で呼び出されるメソッドを登録、ノートをタッチ/クリックしたとき呼び出されるメソッドを登録を行います。

  _generateNote = (number)->
    # ノートの生成部
    note = new Sprite(80, 80)
    note.image = _game.assets["icon.png"]
    note.number = number
    note.x = 100
    note.y = -100 # 画面外に配置
    note.timing = _timing[number] #タッチ/クリックタイミングnote.timingに設定しておく
    _game.rootScene.addChild(note) # シーンに追加

    # ノートがどのように動作するのかを記述します。
    note.tl.setTimeBased() # tl.setTimeBased()を実行することでenchant.jsのアニメーションを時間ベースで実行することができます。
                           # デフォルトはフレームベースであるため「何秒間でここからここまで移動」というような処理ができないためです。

    # ノートのY座標を現在の位置(すなわち-100px)から380pxまで```_timing[number] - _yt.getCurrentTime()) * 1000```msecかけて移動するよう設定しています。
    # ```_timing[number]```という目標時間から```_yt.getCurrentTime()```という現在時間の差を求めどれだけの時間で移動すべきかを計算しています。
    note.tl.moveY(380, (_timing[number] - _yt.getCurrentTime()) * 1000)

    # ノートをタッチ/クリックしたとき呼び出されるメソッドを登録を行います。。
    note.addEventListener "touchstart", (e)->
      @clearTime = _yt.getCurrentTime() # タッチ/クリック時の時間をclearTimeとしてnoteのプロパティに登録しておきます。
      @clear = true # クリアフラグをたてます。

    # 33.3333..ms周期(すなわち30FPS)で呼び出されるメソッドを登録
    note.addEventListener "enterframe", ->
      # タッチ/クリックタイミングから1秒以上経過していればノートをシーンより削除します。
      if _yt.getCurrentTime() > _timing[@number] + 1 then _game.rootScene.removeChild(@)

      # クリアフラグが立っていれば
      if @clear
        # ここでノートをタッチ/クリックしたときの処理を記載します。
        # 以下の処理も33.3333..ms周期で実行されるため、33.3333..ms毎にノートを透明にしていく処理とノートを大きくする処理を記述しています。
        @opacity -= 0.2   # 不透明度を減らしていきます。
        @scale(@scaleX + 0.05, @scaleY + 0.05) # 5%ずつスケールアップさせる

        # ノートが完全に透明になったら
        if @opacity <= 0
          _game.rootScene.removeChild(@) # シーンから削除します。

          # ```note.addEventListener "touchstart", (e)->```内で登録したタッチ/クリック時の時間とタッチ/クリックタイミングを比較し±0.2秒であれば"COOL"、±0.4秒であれば、"GOOD"、それ以外は"BAD"としています。
          if -0.2 <= @clearTime - _timing[@number] <= 0.2 then _judge.text = "COOL"
          else if -0.4 <= @clearTime - _timing[@number] <= 0.4 then _judge.text = "GOOD"
          else _judge.text = "BAD"

メインフレーム部

_proccesRootSceneFrame は33.3333..ms周期(すなわち30FPS)で呼び出されるメソッドです _game.onload 内で登録されています。 処理内容は以下です。

  1. ノートを生成すべきであればノートを生成
  2. 再生時間が終了時間を超えていればゲーム終了処理を行う
  _proccesRootSceneFrame = ->
    if _status is "playing"
      if _isNoteGenerateTiming()
        _generateNote(_timingIndex)
        _timingIndex++

      # 再生時間が終了時間以上?
      if _yt.getCurrentTime() >= _endTime
        # 動画のボリュームを落としていく。
        _yt.setVolume(_youtube.getVolume() - 1)
        # ボリュームが0になったら再生終了し、ステータスを"end"に設定
        if _yt.getVolume() <= 0
          _yt.stop()
          _status = "end"

youtube制御クラス

動画制御クラスですが、公式のサンプルをclassに閉じ込めただけなので詳細は省きます。 詳細はhttps://developers.google.com/youtube/js_api_reference?hl=jaを参照してください。 以下のメソッドで動画を制御可能です。

  • play : 動画を再生
  • getCurrentTime : 再生時間を取得
  • setVolume : 音量を設定
  • getVolume : 現在の音量を取得
  • isReady : Youtubeプレイヤーの準備ができているか問い合わせる
class @Yt
  _player = null
  _isReady = false
  _state = null
  constructor : (parms)->
    tag = document.createElement('script')
    tag.src = 'https://www.youtube.com/iframe_api'
    firstScriptTag = document.getElementsByTagName('script')[0]
    firstScriptTag.parentNode.insertBefore(tag, firstScriptTag)
  play : -> _player.playVideo()
  getCurrentTime : -> _player.getCurrentTime()
  setVolume : (volume)-> _player.setVolume(volume)
  getVolume : -> _player.getVolume()
  isReady : -> _isReady
  onPlayerReady = -> _isReady = true
  window.onYouTubeIframeAPIReady = ->
    _player = new YT.Player 'player',
      events:
        'onReady': onPlayerReady

実行部

newすることにより、constructor_game.onload の順にコールされ、ゲームが開始されます。

new Game()

コンパイル

書き終えたらmain.coffeeをコンパイルしてください。 main.jsが生成されますのでこれをhtmlファイルで読み込みます。 コンパイルは公式サイトの「TRY COFFEESCRIPT」からも可能です。たぶん。

CoffeeScript

html

<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="enchant-stage"></div>
    <script type="text/javascript" src="enchant.js"></script> <!-- enchant.jsは予めダウンロードしておいてください-->
    <script type="text/javascript" src="main.js"></script> <!-- 今回書いたものをコンパイルしたもの -->
  </body>
</html>

注意点など

この程度であればなんとか動作すると思いますが、規模が大きくなってくると現状の作りではパフォーマンス面で苦しくなってくるかと思います。 簡素化のため現状メモリの管理はまったく気にせず、好き放題やっていますが、このようなつくりの場合GC(ガベージコレクション)が、がしがし走るかとおもいます。 GCが走っている間はユーザのプログラムは止まった状態となるので、動きがカクカクしたり、一瞬止まったりする原因になります。 ここでは割愛(というか自分もよくわかっていない)しますがゲームを作る上では必要な知識となります。 本格的に勉強される方は以下の記事も参考にしてみてください。

wise9 › enchant.jsで3Dゲームを作ってみる!

オブジェクトプールを使った静的メモリ JavaScript - HTML5 Rocks

スプライトをプールして使い回す - enchant.jsのTipsを集めるWiki

ソース

main.coffee

class @Game
  YOUTUBE_ID = 'HNYkOJ-T63k'
  _game = null
  _yt = null
  _judge = null
  _timing = [6.14,7.486,8.155,9.977,10.377,11.611,12.062,12.765,13.583,13.945,14.223,14.707,15.059,16.241,16.577,17.425,20.186,20.917,21.593,22.313,22.449,23.123,24.297,24.965,25.113,25.464,26.148,26.635,27.294,28.103,30.910,31.601,32.305,33.024,34.054,34.786,35.360,36.140,37.028,38.402,38.829,39.129,40.354,41.051,41.553,42.233,43.043,43.729,44.261,45.705,46.448,47.416,48.407,50.158,51.310,52.363,53.031,54.417,55.288,55.640,56.472,57.190,58.110,59.095,59.648,60.776, 61.993, 62.370, 63.072, 63.808, 64.493, 65.111, 65.688, 66.414, 67.192, 68.891, 69.209, 69.918, 70.056, 71.111, 71.744, 72.428, 72.861, 73.263, 73.755, 74.099, 74.639, 75.090, 75.426, 75.941, 76.472, 76.992]
  _timingIndex = 0
  _status = "stop"
  _endTime = 80

  constructor : (parms)->
    enchant()
    _game = new Core(800, 600)
    _game.fps = 30
    _game.preload("icon.png", "shadow.png")
    _game.start()
    _game.onload = ->
      _game.rootScene.addEventListener "touchstart", (e)->
        if _yt.isReady()
          _game.rootScene.addEventListener "enterframe", _proccesRootSceneFrame
          _status = "playing"
          _yt.play()
      video = new Entity()
      video._element = document.createElement('div')
      video.x = 500
      video.y = 300
      video._element.innerHTML = '<iframe src="https://www.youtube.com/embed/'+YOUTUBE_ID+'?enablejsapi=1&controls=0&showinfo=0&autoplay=0&rel=0&vq=small"  width="300" height="200" frameborder="0" id="player"></iframe>'
      _game.rootScene.addChild(video)
      _yt = new Yt()
      _judge = new Label()
      _judge.font = "36px Arial"
      _judge.x = 100
      _judge.y = 100
      _game.rootScene.addChild(_judge)
      shadow = new Sprite(80, 80)
      shadow.image = _game.assets["shadow.png"]
      shadow.x = 100
      shadow.y = 380
      _game.rootScene.addChild(shadow)

  _isNoteGenerateTiming = ->
    if _timing[_timingIndex]?
      if _yt.getCurrentTime() > _timing[_timingIndex] - 1
        return true
    return false
  _generateNote = (number)->
    note = new Sprite(80, 80)
    note.image = _game.assets["icon.png"]
    note.number = number
    note.x = 100
    note.y = -100
    note.timing = _timing[number]
    _game.rootScene.addChild(note)
    note.tl.setTimeBased()
    note.tl.moveY(380, (_timing[number] - _yt.getCurrentTime()) * 1000)
    note.addEventListener "touchstart", (e)->
      @clearTime = _yt.getCurrentTime()
      @clear = true
    note.addEventListener "enterframe", ->
      if _yt.getCurrentTime() > _timing[@number] + 1 then _game.rootScene.removeChild(@)
      if @clear
        @opacity -= 0.2
        @scale(@scaleX + 0.05, @scaleY + 0.05)
        if @opacity <= 0
          _game.rootScene.removeChild(@)
          if -0.2 <= @clearTime - _timing[@number] <= 0.2 then _judge.text = "COOL"
          else if -0.4 <= @clearTime - _timing[@number] <= 0.4 then _judge.text = "GOOD"
          else _judge.text = "BAD"
  _proccesRootSceneFrame = ->
    if _status is "playing"
      if _isNoteGenerateTiming()
        _generateNote(_timingIndex)
        _timingIndex++
      if _yt.getCurrentTime() >= _endTime
        _yt.setVolume(_youtube.getVolume() - 1)
        if _yt.getVolume() <= 0
          _yt.stop()
          _status = "end"

class @Yt
  _player = null
  _isReady = false
  _state = null
  constructor : (parms)->
    tag = document.createElement('script')
    tag.src = 'https://www.youtube.com/iframe_api'
    firstScriptTag = document.getElementsByTagName('script')[0]
    firstScriptTag.parentNode.insertBefore(tag, firstScriptTag)
  play : -> _player.playVideo()
  getCurrentTime : -> _player.getCurrentTime()
  setVolume : (volume)-> _player.setVolume(volume)
  getVolume : -> _player.getVolume()
  isReady : -> _isReady
  onPlayerReady = -> _isReady = true
  window.onYouTubeIframeAPIReady = ->
    _player = new YT.Player 'player',
      events:
        'onReady': onPlayerReady

new Game()

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="enchant-stage"></div>
    <script type="text/javascript" src="enchant.js"></script>
    <script type="text/javascript" src="main.js"></script>
  </body>
</html>

その他

もう少しわかりやすくして、おっ立ち野郎 (id:ottati)さんのコードレシピ - 初心者のための簡単プログラミングレシピサイトに登録させてもらおう。