鳥小屋.txt

主に自作ゲームをつくったりしているよ。制作に関することやそうじゃないことのごった煮ブログ

第9羽「青山ツクールマウンテン」

この投稿は「ごちうさ住民 Advent Calendar 2014」の9日目の記事です。

昨日はkazuheiさんの『私の所持金52円!!』でした。
雑コラメーカーのスマホ対応、心よりお待ちしております(チラッチラッ


ごちうさ1羽コメントスクリプトさん for RGSS3

概要

RPGツクールVX Ace(RGSS3)用のスクリプト素材です。
ゲーム画面にニコニコ動画の『ご注文はうさぎですか? 第1羽「ひと目で、尋常でないもふもふだと見抜いたよ」』のコメントを表示します。

f:id:ru_shalm:20141208005237p:plain

f:id:ru_shalm:20141208005245p:plain

f:id:ru_shalm:20141208235409p:plain

利用規約

いつも通りテキトーにどうぞ。許諾も表記も何もナシでOKです。
ただし、画面に流れるコメントにはそれぞれ権利があるのでご注意ください。

なお、本スクリプトに関しては完全に一切のサポートを行いません。よろしくお願いします(ぺこり)

スクリプト

# coding: utf-8
#===============================================================================
# ■ ごちうさ1羽コメントスクリプトさん for RGSS3
#-------------------------------------------------------------------------------
# 2014/12/09 Ru/むっくRu (@ru_shalm)
#-------------------------------------------------------------------------------
# ゲーム画面にニコニコ動画の
# 『ご注文はうさぎですか? 第1羽「ひと目で、尋常でないもふもふだと見抜いたよ」』の
# コメントを表示します。
#===============================================================================

module Torigoya
  module Base64
    def self.encode(str)
      [str].pack('m*')
    end

    def self.decode(str)
      str.unpack('m*').join
    end
  end
end

module Torigoya
  module Win32
    INTERNET_OPEN_TYPE_PRECONFIG = 0
    INTERNET_SERVICE_HTTP = 3

    def self.internet_open
      ::Win32API.new('wininet.dll', 'InternetOpen', %w(p l p p l), 'l')
    end

    def self.internet_connect
      ::Win32API.new('wininet.dll', 'InternetConnect', %w(p p l p p l l l), 'l')
    end

    def self.http_open_request
      ::Win32API.new('wininet.dll', 'HttpOpenRequest', %w(p p p p p p l l), 'l')
    end

    def self.http_send_request
      ::Win32API.new('wininet.dll', 'HttpSendRequest', %w(p p l p l), 'i')
    end

    def self.internet_read_file
      ::Win32API.new('wininet.dll', 'InternetReadFile', %w(l p l p), 'l')
    end

    def self.internet_close_handle
      ::Win32API.new('wininet.dll', 'InternetCloseHandle', %w(l), 'l')
    end
  end
end

module Torigoya
  class URI
    # URLエンコード
    # @param [String] str
    # @return [String]
    def self.encode(str)
      str.to_s.gsub(/([^A-Za-z0-9\-_\.!\~\*\'\(\);\/\?\:@&=\+\$,\[\]])/) do
        '%' + $1.unpack('H*').join('').scan(/.{2}/).join('%').upcase
      end
    end

    def self.parse(uri)
      self.new(uri)
    end

    def initialize(uri)
      /\A(?<scheme>[^:]+):\/\/(?<host>[^\/:]+)(?::(?<port>\d+))?(?<path>\/[^\?]*)?(?:\?(?<query>.*))?\z/ =~ uri
      @scheme = scheme.downcase
      @host = host
      @port = (port || default_port).to_i
      @path = path || '/'
      @query = query || ''
    end
    attr_reader :scheme
    attr_reader :host
    attr_reader :port
    attr_reader :path
    attr_reader :query

    private
    def default_port
      case self.scheme
      when 'http'
        80
      when 'https'
        443
      else
        raise 'unknown scheme'
      end
    end
  end

  module Http
    def self.client
      @client ||= Client.new
    end

    class Client
      SESSION_NAME = 'Torigoya RGSS'

      def get(uri, params = {}, headers = [])
        request('GET', uri, params, headers)
      end

      def post(uri, params = {}, headers = [])
        request('POST', uri, params, headers)
      end

      private
      def request(method, uri_str, params = {}, headers = [])
        uri = URI.parse(uri_str)
        payload = nil
        result_body = []

        headers.push "Host: #{uri.host}"
        if params.kind_of?(String)
          path = uri.path
          payload = params
        else
          if method == 'GET'
            # TODO: uri.queryとdeep_mergeしましょう
            query = convert_query(params)
            path = "#{uri.path}?#{query}"
          else
            query = convert_query(params)
            path = "#{uri.path}#{uri.query ? "?#{uri.query}" : ''}"
            payload = params
          end
        end

        begin
          handle = Torigoya::Win32.internet_open.call(SESSION_NAME, Torigoya::Win32::INTERNET_OPEN_TYPE_PRECONFIG, nil, nil, 0)
          http_session = Torigoya::Win32.internet_connect.call(handle, uri.host, uri.port, nil, nil, Torigoya::Win32::INTERNET_SERVICE_HTTP, 0, 0)
          http_request = Torigoya::Win32.http_open_request.call(http_session, method, path, nil, nil, nil, 0, 0)
          Torigoya::Win32.http_send_request.call(http_request, headers.join("\n"), -1, payload, (payload ? payload.size : 0))

          while true
            buf = "\0" * 10240
            read_size = "\0" * 4 # DWORD
            break if Torigoya::Win32.internet_read_file.call(http_request, buf, buf.size, read_size) && (size = read_size.unpack('i*')[0]) == 0
            result_body.push buf[0, size]
          end
        rescue => e
          puts e.inspect
          puts $@
          result_body = []
        ensure
          Torigoya::Win32.internet_close_handle.call(http_request) if http_request
          Torigoya::Win32.internet_close_handle.call(http_session) if http_session
          Torigoya::Win32.internet_close_handle.call(handle) if handle
        end

        result_body.join
      end

      def convert_query(params = {})
        params.to_a.map { |n| n.map { |n| URI.encode(n) }.join('=') }.join('&')
      end
    end
  end
end

module Torigoya
  module PoorXml
    # 超簡易XMLパーサー
    # - UTF-8以外読み込ませちゃダメ
    # - コメント(<!-- -->)を含むやつもダメ
    # - というかいろいろ読めない
    class Parser
      # 初期化
      # @param [String] raw_doc XMLテキスト
      def initialize(raw_doc)
        @raw_doc = raw_doc
      end

      # パース処理
      # @return [Node] XMLのrootノード
      def parse
        buffer = ''
        str = @raw_doc.dup
        stacks = []
        root = Node.new('root')
        stacks.push root

        while (str.strip! || str.size > 0)
          case str
          when /^(<\?xml.+\?>)/ # XMLヘッダ(UTF-8以外読まないので無視)
            str.sub!($1, '')
          when /^(<([^>]+)\/>)/ # 単独タグ
            params = $2
            str.sub!($1, '')
            child = line_to_node(params)
            stacks.last.children.push child
          when /^(<\/([^>]+)>)/ # 閉じタグ
            name = $2
            str.sub!($1, '')
            stacks.pop
          when /^(<([^>]+)>)/ # 開始タグ
            params = $2
            str.sub!($1, '')
            child = line_to_node(params)
            stacks.last.children.push child
            stacks.push child
          when /^([^<]+)</
            text = $1
            str.sub!($1, '')
            child = TextNode.new('text')
            child.text = text
            stacks.last.children.push child
          else
            raise 'invalid xml'
          end
        end

        root
      end

      private
      def line_to_node(line)
        name, attr_str = line.split(/\s+/, 2)
        node = Node.new(name)

        if attr_str
          i = 0
          key = ''
          value = ''
          read_key_flag = true
          while i < attr_str.size
            if read_key_flag
              case attr_str[i]
              when /\s/
              when '='
                read_key_flag = false
                i += 1 # 次は「"」のはずなのでスキップ
              else
                key << attr_str[i]
              end
            else
              case attr_str[i]
              when "\\"
                i += 1
              when '"'
                read_key_flag = true
                node.attributes[key] = value
                key = ''
                value = ''
              else
                value << attr_str[i]
              end
            end
            i += 1
          end
        end

        node
      end
    end

    class Node
      # 初期化
      # @param [String] name ノード名
      def initialize(name)
        @name = name
        @attributes = {}
        @children = []
      end
      attr_reader :name
      attr_reader :attributes
      attr_reader :children

      # ノードの子の中から指定の名を持つものを取り出す
      # @param [String] name 検索するノードの名前
      # @return [Array] 配列
      def [](name)
        self.children.select { |child| child.name == name }
      end

      # 属性の取得
      # @param [String] name 属性名
      # @return [Object] 指定属性の値
      def attr(name)
        self.attributes[name]
      end

      # ノード内に含むテキストを返す
      # @return [String] 子ノードのテキストを全て連結した文字列
      def text
        self.children.map(&:text).join('')
      end

      # Hash化する
      # @return [Hash] Hash化した値
      def to_h
        {
          name: @name,
          attributes: @attributes,
          children: @children.map(&:to_h),
        }
      end
    end

    class TextNode < Node
      # 初期化
      # @param [String] name ノード名
      def initialize(name)
        super
        @text = ''
      end
      attr_accessor :text

      # Hash化する
      # @return [Hash] Hash化した値
      def to_h
        super.tap { |o| o['text'] = @text }
      end
    end
  end
end

module Torigoya
  module NicoVideo
    class Comment
      def initialize(no, user_id, vpos, mail, text)
        @no = no
        @user_id = user_id
        @vpos = vpos
        @mail = mail
        @text = text
      end
      attr_reader :no
      attr_reader :user_id
      attr_reader :vpos
      attr_reader :mail
      attr_reader :text
    end

    class CommentLoader
      def initialize(thread_id, message_url)
        @thread_id = thread_id
        @message_url = message_url
      end

      def fetch
        fetch_comment_xml(fetch_threadkey)
      end

      def fetch_threadkey
        resp = Torigoya::Http.client.get(
          "http://flapi.nicovideo.jp/api/getthreadkey",
          thread: @thread_id
        )
        response_set = {}
        resp.split('&').each do |n|
          set = n.split('=', 2)
          response_set[set[0].to_s] = set[1].to_s
        end
        response_set
      end

      def fetch_comment_xml(threadkey_data, size = 1000)
        thread_key = threadkey_data['threadkey']
        force_184 = threadkey_data['force_184']
        message_server_url = 'http://msg.nicovideo.jp/24/api/'
        data = "<thread res_from=\"-#{size}\" version=\"20061206\" thread=\"#{@thread_id}\" threadkey=\"#{thread_key}\" force_184=\"#{force_184}\" scores=\"1\" />"
        Torigoya::Http.client.post(
            message_server_url,
            data,
        )
      end
    end
  end
end

module Torigoya
  class RabbitComment
    # ごちうさ1羽のコメントサーバ情報
    # ログイン状態でgetflv API叩いて取得できるよ
    MESSAGE_SERVER_URL = 'http://msg.nicovideo.jp/24/api/'
    THREAD_ID = '1397552685'

    def self.load
      rc = RabbitComment.new
      rc.data['comments']
    end

    def initialize
      @data = nil
      load_comment
      unless @data['fetched_at'] and @data['fetched_at'] > Time.now.to_i - (60 * 60 * 3)
        @data['fetched_at'] = Time.now.to_i
        @data['comments'] = fetch_comment
        File.open('tmp/comment.dat', 'wb') do |f|
          Marshal.dump(@data, f)
        end
      end
    end
    attr_reader :data

    def load_comment
      Dir.mkdir('tmp') unless File.exist?('tmp')
      if File.exist?('tmp/comment.dat')
        begin
          File.open('tmp/comment.dat', 'rb') do |file|
            @data = Marshal.load(file)
          end
        rescue => e
          puts e.inspect
        end
      end
      @data ||= {}
    end

    def fetch_comment
      nico_comment = Torigoya::NicoVideo::CommentLoader.new(THREAD_ID, MESSAGE_SERVER_URL)
      parser = Torigoya::PoorXml::Parser.new(nico_comment.fetch)
      doc = parser.parse
      chat_elements = doc['packet'].first['chat'].map { |node|
        Torigoya::NicoVideo::Comment.new(
          node.attr('no').to_i,
          node.attr('user_id'),
          node.attr('vpos').to_i,
          (node.attr('mail') || '').split(/\s+/),
          node.text,
        )
      }.sort { |a, b| a.vpos <=> b.vpos }
    end
  end
end

module Torigoya
  module CommentPlayer
    module Manager
      def self.init(comments)
        @stage = Spriteset_Stage.new(comments)
      end

      def self.stage
        @stage
      end
    end

    class Sprite_Comment < Sprite
      def initialize(sprite_set, viewport = nil)
        super(viewport)
        @sprite_set = sprite_set
        @test_bitmap = Bitmap.new(1, 1)
        @speed = 1
        @life = -1
        self.z = 255
      end

      def dispose
        @test_bitmap.dispose
        super
      end

      def set(comment)
        unset
        @comment = comment

        self.bitmap.dispose if self.bitmap
        apply_font(@test_bitmap)
        lines = @comment.text.strip.split(/\r?\n/)
        rect = @test_bitmap.text_size(lines.max_by(&:size))
        if self.ue? or self.shita?
          rect.width = Graphics.width unless rect.width < Graphics.width
          align = 1
        else
          align = 0
        end

        self.bitmap = Bitmap.new(rect.width, rect.height * lines.size)
        apply_font(self.bitmap)
        lines.each do |line|
          self.bitmap.draw_text(rect, line, align)
          rect.y += rect.height
        end

        @speed = 0
        case
        when self.ue?
          self.x = (Graphics.width - self.bitmap.width) / 2
          self.y = @sprite_set.find_ue_position(self)
        when self.shita?
          self.x = (Graphics.width - self.bitmap.width) / 2
          self.y = @sprite_set.find_shita_position(self) - self.bitmap.height
        else
          self.x = Graphics.width
          @speed = (Graphics.width + self.bitmap.width) / (4.0 * Graphics.frame_rate)
          self.y = rand(Graphics.height - self.bitmap.height)
        end
        @life = Graphics.frame_rate * 4
      end

      def apply_font(bitmap)
        bitmap.font.size =
          case
          when @comment.mail.include?('small')
            24
          when @comment.mail.include?('big')
            48
          else
            32
          end

        # TODO: 16進数指定もあるよ
        bitmap.font.color =
          case
          when @comment.mail.include?('red')
            Color.new(255, 0, 0)
          when @comment.mail.include?('pink')
            Color.new(255, 128, 128)
          when @comment.mail.include?('orange')
            Color.new(255, 192, 0)
          when @comment.mail.include?('yellow')
            Color.new(255, 255, 0)
          when @comment.mail.include?('green')
            Color.new(0, 255, 0)
          when @comment.mail.include?('cyan')
            Color.new(0, 255, 255)
          when @comment.mail.include?('blue')
            Color.new(0, 0, 255)
          when @comment.mail.include?('purple')
            Color.new(192, 0, 255)
          when @comment.mail.include?('black')
            Color.new(0, 0, 0)
          when @comment.mail.include?('white2'), @comment.mail.include?('niconicowhite')
            Color.new(204, 204, 153)
          when @comment.mail.include?('red2'), @comment.mail.include?('truered')
            Color.new(204, 0, 51)
          when @comment.mail.include?('pink2')
            Color.new(255, 51, 204)
          when @comment.mail.include?('orange2'), @comment.mail.include?('passionorange')
            Color.new(255, 102, 0)
          when @comment.mail.include?('yellow2'), @comment.mail.include?('madyellow')
            Color.new(153, 153, 0)
          when @comment.mail.include?('green2'), @comment.mail.include?('elementalgreen')
            Color.new(0, 204, 102)
          when @comment.mail.include?('cyan2')
            Color.new(0, 204, 204)
          when @comment.mail.include?('blue2'), @comment.mail.include?('marineblue')
            Color.new(51, 153, 255)
          when @comment.mail.include?('purple2'), @comment.mail.include?('nobleviolet')
            Color.new(102, 51, 204)
          when @comment.mail.include?('black2')
            Color.new(102, 102, 102)
          else
            Color.new(255, 255, 255)
          end
      end

      def unset
        @comment = nil
        @life = 0
        self.bitmap.dispose if self.bitmap
      end

      def update
        if @comment
          self.x -= @speed
          @life -= 1
          if @life <= 0
            self.unset
          end
        end
        super
      end

      def living?
        @life > 0
      end

      def ue?
        @comment && @comment.mail.include?('ue')
      end

      def shita?
        @comment && @comment.mail.include?('shita')
      end
    end

    class Spriteset_Stage
      SPRITE_SIZE = 50

      def initialize(comments)
        @comments = comments
        @comment_index = 0
        @timer = 0
        @prev_frame_count = Graphics.frame_count 

        @viewport = Viewport.new
        @viewport.z = 65535
        @sprites = Array.new(SPRITE_SIZE).map { Sprite_Comment.new(self, @viewport) }
        @sprite_index = 0
      end

      def dispose
        @sprites.each do |sprite|
          sprite.bitmap.dispose if sprite.bitmap
          sprite.dispose
        end
        @viewport.dispose
      end

      def update
        # 時間を進める
        prev_timer = @timer
        @timer += ((Graphics.frame_count - @prev_frame_count).to_f / Graphics.frame_rate)
        @prev_frame_count = Graphics.frame_count

        # 経過時間中に登場するコメントを流す
        while @comments[@comment_index] && @comments[@comment_index].vpos <= (@timer * 100)
          @sprites[@sprite_index].set(@comments[@comment_index])
          @sprite_index = (@sprite_index + 1) % SPRITE_SIZE
          @comment_index += 1
          unless @comment_index < @comments.size
            @comment_index = 0
            @timer = 0
          end
        end

        @viewport.update
        @sprites.each(&:update)
      end

      # ue表示の表示位置を探して返す
      def find_ue_position(target_sprite)
        y = 0
        @sprites.select(&:living?).select(&:ue?).sort { |a, b| a.y <=> b.y }.each do |sprite|
          return y if y + target_sprite.bitmap.height < sprite.y
          y = sprite.y + sprite.bitmap.height
        end
        y
      end

      # shita表示の表示位置を探して返す
      def find_shita_position(target_sprite)
        y = Graphics.height
        @sprites.select(&:living?).select(&:shita?).sort { |a, b| b.y <=> a.y }.each do |sprite|
          return y if y - target_sprite.bitmap.height > sprite.y + sprite.bitmap.height
          y = sprite.y
        end
        y
      end
    end
  end
end

class << DataManager
  alias gochiusa_init init
  def init
    gochiusa_init
    comments = Torigoya::RabbitComment.load
    Torigoya::CommentPlayer::Manager.init(comments)
  end
end

class Scene_Base
  alias gochiusa_update_basic update_basic
  def update_basic
    gochiusa_update_basic
    Torigoya::CommentPlayer::Manager.stage.update
  end
end

一見、めっちゃ長いんですけど、RGSSからnet/httpとrexmlが使えないのが悪いんです…!XMLパーサー書くとか昭和かよ。

注意

  • コメント表示の再現度が絶望的にダメです
    • ニコ動のコメントシステム複雑すぎてつらいので誰かまとめてほしい
  • コメントの取得数が足りません(直近1000件しか取ってきてない)
  • よくコメント表示レイヤーが消失します
    • シーンをまたがったSprite大量に置くのよくないと思う
  • 間違ってもマジメなゲームに使ってはダメです

ごちうさの話

当時、タイトルの「うさぎ」に釣られて1羽を視聴しました。
「なるほどな」という感じだったので本来ならそのまま視聴継続になるはずだったのですが、4月当時、僕は完全に炎上案件の真っ只中で、仕事と睡眠を繰り返す日々で気づいたら夏になっており、僕の中でごちうさは1羽が最終回になってしまいました。
あのとき、リアルタイムに視聴していたら僕もどうなっていたかわかりませんね。。。

しかし、幸か不幸かはわかりませんが、弟がごちうさ民になっており、現在では家に原作もBlue-rayもある状態のため、ごちうさ分に関しては特に問題がない状態になっています。なので原作は3巻まで読みました。あと一応、電子版も買ったです。

シャロちゃんとリゼ先輩の関係がとても良いですね。
あの2人の関係は見てるだけでこう…グッとね。はい。


明日はゆっくりしないさんです٩(๑❛ᴗ❛๑)۶