mobageで運営していたゲームを終了させた

概要

mobage & Yahoo! mobageで運営していたゲームを終了申請しており、ついに今月終了となった。 プログラムを見ると2014年のものがいくつかあるので恐らく3年弱くらいになるだろう。

環境

背景

mobage等、課金システムのあるOpenSocialのプラットホームは法人でなくてはならない。 自分はフリーランスでエンジニアをしていたが、 OpenSocialのアプリで稼ぎたいと思ったので、mixiアプリが出始めた頃に法人化した。 それでmixiGREEmobageに登録し、それぞれアプリを出した。

mixiはまだネココさんというアプリが動いていると思う(誰もプレイしてはいないと思うが)。 GREEもネココさんを動かしていたが、サーバー代が厳しいので終了させた。

mobageはエンディングロードというRPGを出した。AndroidとPCブラウザのハイブリッド。 こちらもサーバー代が厳しかったのでさくらの安いVPSに途中で変更してほそぼそと続けていた。

f:id:dala:20180208221803j:plain

f:id:dala:20180208221839j:plain

終了した理由

売上はほとんど無かった。 10連ガチャが初回300円なのだが、3000円で引いているユーザーなんて見たことがない。

でも安いVPSに移動して費用的な負担はほとんど無かったので、記念にずっと残しておこうとは思っていた。 ただ、やっぱり色々懸念点が出てきたので終了することを決めた。 具体的な理由としては下記のようなもの。

問い合わせ対応が大変

基本的に問い合わせはちゃんと24時間以内に1次対応をしなければならない。 しかし、土日だってあるし仕事だってあるので毎日見てられない。 問い合わせが来たとしてもほとんどAndroid経由の営業スパムだし。

さらに、僕が普段使うPCをLinux Mintしたことで問い合わせのソフトが使えなくなってしまった。 問い合わせが来たらWindowsに切り替えるかmacを立ちあげなくてはいけない。 正直もうだいぶ負担だった。

SSL

いつからかChromeSSLでない場合に警告を出すようになった。 世の中はSSLが当たり前に変わっていっている。 恐らく、そのうちChromemobageSSL必須化し、SSL対応を迫られる日が来ることが予想された。

現在無料SSLができるのでそれほど技術的に問題はないのだが、 とはいえ既に稼働中のサーバーであれこれ操作するのは怖い。

それよりも一番問題なのが、先程も書いたようにLinux Mintに変えてしまったので、 開発環境をWindows側に残したままだった。 SSL対応のためにいちいちWindows(起動がすごく重い)に切り替えるのは大変。 しかも実装するとなるとまたあれこれ修正してテストもやり直さなければならない。 かなりの重労働で、ただでさえ仕事で忙しいのに厳しい。

責任

上記の問題点を考えているうちにふと思ったのが、 もし明日、明後日急にSSL必須になったらどうしよう、という思い。

もちろんmobageはちゃんとしていてそういうことは猶予をもってお知らせがあるので問題はないのだが、 例え1,2ヶ月であっても今の自分としては辛い。

そんなこんなやっているうちに対応できずSSL必須化になってしまったら、サービスは止まってしまう。 もしかすると正しく運用出来ないことで責任を追求されたりするのでは…等という考えが頭をめぐった。

サービス終了はもはや当然の決断だった。

最後の問い合わせ

不具合の問い合わせが来たのであれこれやり取りして修正した。 今回はDB側で修正すればよかったので問題なかったのだが、 プログラム側の修正が必要だったらと思うとゾッとした。 これが引き金になったと思う。

終了した

終了申請をし、課金終了の準備をしてお知らせをし、課金終了までに約1ヶ月、そこから終了までに約1ヶ月、無事終了することが出来た。 ちなみにこのあとも問い合わせ対応を行わなければならない。

ソース

公開したが、古く汚いので、動かなくて困っている、くらいの人でないと役には立たないものと思われる。

cocos側のプログラム。

GitHub - dala00/endingroad: Ending Road game client

READMEにも書いてあるが、フリー素材などは念の為再配布にならないよう全部削除している。

PHPで書かれたサーバー側は、 万が一mobageの漏洩させてはならない情報を削除し忘れて漏らしてはいけないので、 結構勢い良く適当にフォルダ毎がっつり削除した。 (それでもまだ怖いくらい)

GitHub - dala00/endingroad-server: Ending Road (server application)

もう不要だしPSR-2すら知らない頃の汚いプログラムなので何の参考にもならないが、 丸々欲しい人がいれば(機密部分は当然省いて)ゆずるので問い合わせして下さい。

一問一答

どんなゲームだったのか

当時リリースされたテラバトルを見て、これなら自分の作ったゲームも売れるんじゃないかと勘違いしてリリースした、簡易ストーリー型のステージクリア型RPG

クソゲーだったか

プレイする人によるのではないかと思う。 クソゲーだと思う人もいるだろうし。頑張って作ったので割と遊べるのではないかとも思う。 フリー素材のクオリティも全般的に良かった。

経験値量的にすごく大変なはずなのにイベントキャラ全部最大レベルまで上げてくれた人もいた。本当に感謝。 ちなみにイベントキャラは+99するとSSRよりちょっと強い。(だった気がする)

リジェクトされなかったのか

一人で作ったので一般のアプリよりはしょぼかったと思うが、そういう理由ではとくにリジェクトされなかった。 不備があったりしたところを直していくとちゃんとリリースまで進めた。

エンディングは

実はエンディングのステージを作らずに終了してしまった。

考えていたものとしては、下記へ続いていくようなストーリー。

エルアネット - Google Play の Android アプリ

つまり偉い人たちが増えすぎた人間を排除するために世界は滅亡するという噂を広めた。 人々は楽園をめざすために冒険して減っていき、楽園に着いてもエルアネットに転送される。 (転送されるのか、偉い人側にいくのかは決めていなかった)

ちなみに舞台は未来の地球という設定。エアルスという名前にしようとしたが、 どうもガンダムで使われているようなのでルシアとかで想像していたと思う。

サーバー解約できて良かったですね

上記のアプリが一緒に入っているので解約できなかった…。 誰もプレイしていないみたいだし停止するか記念にGCPに移動してゼロ円運用(移動が面倒)するか検討中。

再公開はするのか

他に作りたいものがどんどん出てくるしそんなことをする暇はないので可能性は少ないが、 もし気が向いて試して一瞬で可能そうだったらやるかもしれない。 ただし可能性はかなり低い。

まとめ

一人でもソーシャルアプリは作れる。会社で複数人いるならもちろん。 こういったプラットホームは何もしなくてもインストールしてもらえるのでありがたいが、それだけでは絶対に足りない。

通常のゲームと同様、自分たちでちゃんと宣伝し、課金率も上げていかないと運営は成功しない。 やるならきちんとやらなければならない。 ゲームを作ってリリースして運用するのはとても楽しい。

技術的な話はQiitaで。

mobageで運営していたゲームのソースを公開 - Qiita

teratailを3日やってみたら週間ランキング3位になった

動機

同じプログラミング関連のサービスである、一人もくもく会の集客目的で、 プロフィールページからの流入が狙えるかどうかを試すためにちょろっと始めてみた。

どうなったか

週間ランキング3位になった

頻繁に回答してると結構早く順位が上がっていく。 3日間空いている時間で回答していっただけであっという間に週間ランキング3位になった。

f:id:dala:20180204214823p:plain

f:id:dala:20180204215027p:plain

完了していなかった質問が4日目で終わったりすると2位まで僅差になったので十分に狙えた。 (もう回答自体やめた)

回答数など

ベストアンサー24 / 回答52

f:id:dala:20180204215059p:plain

まとめ

週間ランキングはすぐ取れる

仕事でエンジニアやってたりするなら誰でもすぐ取れる。10位とかならすぐ行く。

メリットはほぼ無い

元々分かっていたことなのでずっとteratailに興味がなかったのだが、はっきり言って回答者には何のメリットもない。 それどころかちょっと関連するライブラリのマニュアルを調べたり、忘れてる知識を思い出そうと調べ始めるとちょこちょこ時間がかかるので、 大変なのでどちらかというとデメリットが大きい。ずっとはやってられない。

質問をした方は勉強になるかもしれないが、その人が将来無茶苦茶上達して自分を助けに来てくれるわけでもないし、完全なボランティア。 もちろん社会貢献にはなるのでそういう意味ではデメリットとは言わない。 (でも簡単なプログラムを自分で試すのを疲れてしまっただけの課題の人もちょくちょくいる)

一応トップページの週間ランキングに自分の名前が現れるのでプロフィールからTwitterや指定したURLへの集客はできるが、 今のところほとんど効果も無い。 ずっと継続すれば違うのかもしれないが。

あとはscoreを換金したりも出来ないし。 scoreを使って何か回答者が自分のサービスを公開してるのをガッツリ宣伝してくれるとかメリットつけたらいいのに。

ゲームとして楽しむならいいかも

score周りはしっかりとしているので、ゲームとして楽しむのであればいいのではないかと思う。 タグ毎にレベルアップみたいなのがあったり、いい感じに回答してベストアンサーになったらscoreが多くなったり。 しっかり集計されているので見るだけでもそこそこ楽しいかもしれない。

DockerでPHP5.5のLAMP環境を作成

DockerでPHP5.5のLAMP環境を作成した。

元々ローカルのPHPのままだましだまし動かしてたのだが、 シェルだとCakePHP2のObjectクラスがコンフリクトしてついに動かなくなってしまったのでやむなく作成した。

Dockerfile

FROM nyanpass/php5.5:5.5-apache

RUN echo 'date.timezone = "Asia/Tokyo"' > /usr/local/etc/php/conf.d/timezone.ini
RUN a2enmod rewrite
RUN docker-php-ext-install pdo_mysql mysqli mbstring

docker-composer.yml

version: '2'
volumes:
  mysql_data:
    driver: 'local'
services:
  mysql:
    image: mysql:5.5
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "true"

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    environment:
      - PMA_ARBITRARY=1
      - PMA_HOST=mysql
      - PMA_USER=root
      - PMA_PASSWORD=
    ports:
      - 8100:80

  zenkokutenkai:
    image: Dockerfileでビルドしたイメージ名
    volumes:
      - .:/var/www/html
    ports:
      - "8050:80"
    tty: true
    stdin_open: true

こんな古いプロジェクトのために作りたくない…とは思うがこういう状況だからこそDockerが役立つんだよなぁ…。

WerckerでPhoenixアプリケーションのCI

※1.6.0にしてフォーマットを追記

WerckerにてPhoenixアプリケーションのCIをするためのwercker.yml。 DBもservicesで追加できるので専用のコンテナを準備する必要がない。

box: shufo/phoenix:1.6.0

services:
  - id: mariadb
    name: mysql
    username: root
    password: ""
    tag: latest
    env:
      MYSQL_ALLOW_EMPTY_PASSWORD: "true"

build:
  steps:
    - script:
        name: mix format --check-formatted
        code: mix format --check-formatted

    - script:
        name: mix deps.get
        code: mix deps.get

    - script:
        name: mix test
        code: mix test

ほんとはbrunchのビルドもしたかったのだがこのboxだとnpmコマンドが見つからない。 自分で作っているboxを使えば良いのだが、面倒だったのでとりあえずelixir側だけにした。

Goのglide環境にてWerckerのCI導入

Go3 Advent Calendar 2017 - Qiita

23日目

GoのアプリケーションのリポジトリをBitBucketに作っているので、WerckerでのCIを試した。

シンプルで綺麗なパスでアプリケーションを作っている場合、 多分Werckerにてアプリケーションを登録する際に表示されるサンプルそのままのwercker.ymlでそのまま動くのではないかと思う。

ただ、今回のアプリケーションはパッケージ管理にglideを使っているのでそのままでは動かない。 ちなみにフォルダ構成としては、下記に降りたところにアプリケーションが入ってる。

/src/myapp

Dockerコンテナ内では上記のルートをGOPATHとしている。ちょっと変な構成。

とりあえず動くところまでを調整してみた。 とりあえずなので変な感じになっている。

box: golang
build:
  steps:
    - setup-go-workspace

    - script:
        name: install glide
        code: |
          curl https://glide.sh/get | sh

    # Gets the dependencies
    - script:
        name: glide install
        cwd: src/crawler/
        code: |
          glide install

    # Build the project
    - script:
        name: go build
        cwd: src/crawler/
        code: |
          export GOPATH=/go/src/bitbucket.org/myaccount/myapp
          go build

    # Test the project
    - script:
        name: go test
        cwd: src/crawler/
        code: |
          export GOPATH=/go/src/bitbucket.org/myaccount/myapp
          go test $(glide novendor)

細かいところを補足していくと、まずglideのドキュメント通りにインストール

curl https://glide.sh/get | sh

最初に書いたとおりパスがちょっと違うので合わせる。

cwd: src/crawler/
code: |
  glide install

パッケージの位置がずれて読み込めていなかったので、exportでGOPATHを指定している。 (ymlの設定であるかも?)

プロジェクトの環境変数を設定すると今度はglideのインストールとかがうまくいかなくなるのでここで指定している。

cwd: src/crawler/
code: |
  export GOPATH=/go/src/bitbucket.org/myaccount/myapp
  go build

こんな感じで通った。

あとはパイプラインでデプロイの処理などと繋げれば楽だと思う。

サイボウズLiveを作る-第6回-イベント作成

あと一つ大きなメイン機能であるイベント機能が残っていたのでそちらを作成した。

色々見てみた結果、とりあえず全部FullCalendarに置き換えればいいだろうと言う結論に至った。

FullCalendar - JavaScript Event Calendar

期間や範囲切り替えもあるし、これだけで一通りまかなえる気がする。

f:id:dala:20171219224124p:plain

イベント予定メニューマスタ

本家にはない(?)が、予定メニューにも色を付けられるようにした。

f:id:dala:20171217155825p:plain

<template>
  <div class="row">
    <div class="col-12">
      <div class="sample">
        <span class="event-color-sample" v-bind:style="{backgroundColor: currentBgColor.hex, color: currentTextColor.hex}">サンプル</span>
      </div>
    </div>
    <div class="col-12 col-sm-4">
      <div>背景色</div>
      <swatches-picker v-model="currentBgColor"></swatches-picker>
    </div>
    <div class="col-12 col-sm-4">
      <div>文字色</div>
      <swatches-picker v-model="currentTextColor"></swatches-picker>
    </div>
    <input type="hidden" :name="bg_color_name" :value="currentBgColor.hex">
    <input type="hidden" :name="text_color_name" :value="currentTextColor.hex">
  </div>
</template>

<style scoped>
div.row {
  margin-bottom: 20px;
}
</style>

<script>
import { Swatches } from 'vue-color'

export default {
  props: ['bg_color_name', 'text_color_name', 'bg_color', 'text_color'],

  components: {
    'swatches-picker': Swatches,
  },

  data () {
    return {
      currentBgColor: {hex: this.bg_color === undefined ? '#3F51B5' : this.bg_color},
      currentTextColor: {hex: this.text_color === undefined ? '#FFFFFF' : this.text_color},
    }
  },

  methods: {
  }
}
</script>

一覧もvuedraggableを使ってドラッグ&ドロップで簡単に並び替えできるようにした。

面倒かと思ったが、元々のテンプレートをとりあえずコピーから始められるのでそれほどでもなかった。

<template>
  <table class="table">
    <thead>
      <tr>
        <th></th>
        <th>予定メニュー名</th>

        <th></th>
      </tr>
    </thead>
    <draggable v-model="scheduleCategories" :element="'tbody'" :options="{handle: '.handle'}" @end="onEnd">
      <tr v-for="scheduleCategory in scheduleCategories" :key="scheduleCategory.id">
        <td class="handle"><i class="material-icons">drag_handle</i></td>
        <td>
          <span
            v-text="scheduleCategory.name"
            class="event-color-sample"
            v-bind:style="{backgroundColor: scheduleCategory.bg_color, color: scheduleCategory.text_color}"
          ></span>
        </td>

        <td class="text-right">
          <span><a :href="`/${group_id}/schedule-categories/${scheduleCategory.id}/edit`" class="btn btn-default btn-xs">編集</a></span>
          <span>
            <a
              href="#"
              data-confirm="削除してよろしいですか?"
              :data-csrf="csrf"
              data-method="delete"
              :data-to="`/${group_id}/schedule-categories/${scheduleCategory.id}`"
              rel="nofollow"
              class="btn btn-danger btn-xs"
            >削除<div class="ripple-container"></div></a>
          </span>
        </td>
      </tr>
    </draggable>
  </table>
</template>

<style scoped>
.handle {
  cursor: crosshair;
}
</style>

<script>
import draggable from 'vuedraggable'
import axios from 'axios'

export default {
  props: ['group_id', 'schedule_categories'],

  components: {draggable},

  data () {
    return {
      scheduleCategories: this.schedule_categories,
      csrf: document.querySelector('meta[name=csrf]').getAttribute('content'),
    }
  },

  methods: {
    onEnd() {
      axios.put(`/${this.group_id}/schedule-categories/update-order`, {
          ids: this.scheduleCategories.map(c => c.id),
        });
    }
  }
}
</script>

保存側。

  def update_schedule_categories_order(group_id, ids) do
    Enum.with_index(ids)
    |> Enum.each(fn{id, index} ->
      schedule_category = get_schedule_category!(id, group_id)
      update_schedule_category(schedule_category, %{"display_order" => index + 1})
    end)
  end

カレンダー

こちらも面倒かと思ったが、データの取得はコールバックに作ればいいだけだったので非常に楽だった。

<template>
  <div id="calendar">
  </div>
</template>

<style scoped>
</style>

<script>
import axios from 'axios'
import moment from 'moment'

export default {
  props: ['group_id', 'month'],

  data () {
    return {
      currentEvents: [],
    }
  },

  mounted() {
    if (this.month !== undefined) {

    }
    $('#calendar').fullCalendar({
      locale: 'ja',
      header: {
        right:  'month,agendaWeek,agendaDay today prev,next'
      },
      views: {
        month: {
          titleFormat: 'YYYY年 MMMM',
        },
      },
      buttonText: {
        today: '今日',
        month: '月',
        week: '週',
        day: '日',
      },
      firstDay: 1,
      timeFormat: 'HH:mm',
      defaultDate: this.getDefaultDate(),
      events: this.loadEvents.bind(this),
      dayClick: this.dayClick.bind(this),
      eventClick: this.eventClick.bind(this),
    });
  },

  methods: {
    dayClick(date, jsEvent, view) {
      const dateText = date.format('YYYY-MM-DD');
      if (view.name == 'month') {
        location.href = `/${this.group_id}/schedules/new?date=${dateText}`; 
      } else {
        const timeText = date.format('HH:mm:ss');
        location.href = `/${this.group_id}/schedules/new?date=${dateText}&time=${timeText}`; 
      }
    },

    eventClick(calEvent, jsEvent, view) {
      location.href = `/${this.group_id}/schedule-posts/${calEvent.schedule.id}`;
    },

    loadEvents(start, end, timezone, callback) {
      const startDate = start.format('YYYY-MM-DD');
      const endDate = end.format('YYYY-MM-DD');
      axios.get(`/${this.group_id}/schedules/events/${startDate}/${endDate}`)
        .then(response => {
          this.$emit('GET_AJAX_COMPLETE');
          callback(response.data);
        });
    },

    getDefaultDate() {
      if (this.month !== undefined) {
        return moment(this.month + '-01');
      } else {
        return moment();
      }
    }
  }
}
</script>

取得側。 こういう重そうな範囲検索は、大規模サービスだとどう実装してるのか気になる。 日毎にデータを分けてるのかな。(日を子データにしてるとか)大変そう。

  def list_schedules_for_range(group_id, start_date, end_date) do
    query = from s in Schedule,
      where: s.group_id == ^group_id
        and (
          (s.start_date < ^start_date and ^end_date < s.end_date)
          or (^start_date <= s.start_date and s.start_date <= ^end_date)
          or (^start_date <= s.end_date and s.end_date <= ^end_date)
        )
        and is_nil(s.deleted_at)
    Repo.all(query)
    |> Repo.preload(:schedule_category)
  end

不足点

リピートの予定とか放置してる。

あと、設備とかどこで使ってるんだろうと思ったら、アクセスの方法によって登録できたりするっぽい。 完全に抜けてる。

わかりづらいなここは。急にグループ関係なしの画面になるし意味が分からない。

テスト放置なので整備しようかと思う。 そっちの方が面白くて書くこと多かったりするかもしれない。 ソース書いてるのVueばっかりだし。

Copying live

サイボウズLiveを作る-第5回-グループへ参加

とりあえず一旦グループにメンバーを追加する機能を進めてみた。

メールアドレスは今のところ登録してほしくないし、とりあえずそれ無しでできる部分だけ進めた。

具体的には本家と同じで、招待URLを使ってそこからアクセスしてログインすればグループ申請となる形。

というか、本当にほとんどそれくらいなので何も書くことがない。 とりあえず、ただそのまま処理を書いてるだけなので何の役にも立たないがソースでも貼っておく。

  def join_request(conn, %{"id" => id, "invitation_hash" => invitation_hash}) do
    user = Auth.get_user(conn)
    group = Groups.get_group!(id)
    cond do
      invitation_hash != Group.invitation_hash(group) ->
        redirect(conn, to: "/")
      user ->
        Groups.create_invitation(user, group)
        redirect(conn, to: group_path(conn, :index))
      true ->
        conn
        |> put_layout(false)
        |> render("join_request.html", group: group, invitation_hash: invitation_hash)
    end
  end

ログインしていたらそのままメッセージもなく申請データが登録されて自分のページに戻るので、 非常にわかりづらい。さすがに直した方がいいかもしれない。

承認。

  def approve(conn, %{"group_id" => group_id, "id" => id}) do
    user = Auth.get_user(conn)
    group = Groups.get_group!(group_id)
    invitation = Groups.get_invitation!(id, group_id)
    invitation_params = %{"closed_at" => Timex.now}

    result = Repo.transaction(fn ->
      Groups.update_invitation!(invitation, invitation_params)
      Groups.create_group_user!(invitation.user, group)
    end)

    case result do
      {:ok, _changes} ->
        conn
        |> put_flash(:info, "承認しました。")
        |> redirect(to: invitation_path(conn, :requests, group_id))
      {:error, _any} ->
        conn
        |> put_flash(:error, "エラーが発生しました。")
        |> redirect(to: invitation_path(conn, :requests, group_id))
    end
  end

あとはページ上に表示されているユーザーのリンクは単純なusersのshowだったが、 関係ないグループのユーザーも表示できてしまうので全てGroupUserのリンクに変更し、 同じグループのユーザーしかアクセスできないものにした。 自分の編集画面などはid等のパラメータなどもなしのURLに変更。

考察

自分の知っている人を新たなグループに招待する機能もサイボウズLiveにはあり、 それは便利なので必要かなと思う。 今回は申請、許可的な機能だがそちらは招待なので参加する側が許可すれば参加できる、 今回とは逆の機能となる。 招待テーブルを作るのか、フラグで分けるのか、また気が向いた時に本家の画面を見て決めたりなどが必要。

次はイベント機能を進めようかと思う。 それが終わったら今テストを全く触っておらず、自動生成されたままのためエラー出まくりなので、 そっちを一旦整備もしたい。修正したり要らないものを捨てて絞ったり。 (なんとなくそっちの方が書くことがあるような気がする)

あとはイベント機能に伴い、今まで放置してたタイムゾーン問題も少し時間をかけて調べる時間を取ろうと思う。