Laravelで弐寺のクリアランプマネージャーをつくる

[Laravel #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/laravel-2)5日目のの記事です。

#概要・動機
beatmania IIDXというゲームが好きです(唐突)


既存のクリアランプマネージャもあるにはあるが、機能が多くて僕には使いこなせません。
(低難度とか普段やらんしフォルダ分けもいらん)

身の丈にあったツールを使いたいので、クリアランプマネージャー兼地力推定サービスを自分用に~~車輪の再~~開発します。

前提として、自分はMVCが何ぞやということはゆるふわ理解している程度で、フルスタックのwebフレームワークは初心者です。


できたアプリはこんなかんじ↓
<img width="1229" alt="スクリーンショット 2019-12-03 15.30.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261602/76d93930-3b47-14fd-2a88-2457ee7c4600.png">
(開発期間:2日)

<font color='Red'>クリアできそうな楽曲も教えてくれます！！(目玉機能)</font>

###github
https://github.com/dhaiibfiukkiu/estimator

#MVCそれぞれの概要
##モデル
正直ココが全てを決める気がする。

アプリの動作としては、
1.indexページに全楽曲の表とクリアした難易度のラジオボタン,submitボタン
2.ログイン後、submitでそのuserとクリアした楽曲を紐付けたclearsデータを挿入。
3.clearsデータに基づいてラジオボタンをchecked。また、地力の推定も行う。

といったことを想定しているので、userテーブル、musicsテーブル、clearsテーブルがいるかなと思った。

本来userとmusicsは多対多の関係であるので、clearsがうまく中間テーブルとして作用してくれる。
つまり、clearsの複合主キーが**u_id**と**m_id**であるから、新たに主キー**id**を作る必要は無いと感じた。

**このせいで後に苦労する羽目になるのだが**(後述)
![空白の ERD.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261602/6ab18ef1-11a7-d385-9d97-5cb2ea3d264f.png)

musicsテーブルの**e**から**fc**まではそれぞれのクリア難易度(地力値)です。

値は[こちら](https://sp12.iidx.app/recommends)を参考にさせていただきました。

単純にページをコピペすると、それぞれの楽曲は改行(\n)で、それぞれの要素はタブ文字(\t)で区切られていたので、こちらをlaravelのシーディングにうまく合う形に加工します。

```musicTableSeeder.php
<?php                                                                                                                                   
    
    use Illuminate\Database\Seeder;
    use App\Music;
    
    class musicTableSeeder extends Seeder
    {
       /**
        * Run the database seeds.
        *
        * @return void
        */
       public function run()
       {
   
           $param=[
               'name' => '#MAGiCVLGiRL_TRVP_B3VTZ',
               'e' => '-2.914433',
               'n' => '-2.038145',
               'h' => '-1.965019',
               'exh' => '3.882106',
               'fc' => '8.346122',
           ];
           $musics = new Music;
           $musics->fill($param)->save();
     //(以下略)
```
目標はこの形です。そのために以下のPythonスクリプトを書きました。

```makelist.py
input='''(ここに楽曲のリスト)
'''
input=input.split('\n')

for line in input:
    tmp=line.split('\t')
    if tmp[1]=='Infinity':
        continue
    name=tmp[0].replace("'",r"\'")
    e=tmp[1]
    n=tmp[2]
    h=tmp[3]
    exh=tmp[4]
    fc=tmp[5]
    print('''
        $param=[
            'name' => '{}',
            'e' => '{}',
            'n' => '{}',
            'h' => '{}',
            'exh' => '{}',
            'fc' => '{}',
        ];
        $musics = new Music;
        $musics->fill($param)->save();
    '''.format(name,e,n,h,exh,fc))
```
あとはこのスクリプトの出力をmusicのseederスクリプトにぶち込んで`php artisan db:seed`してやるだけです。(勿論テーブルのマイグレーションをした後にです)

あと、appディレクトリ以下のMusic,Clearモデルには以下のように記述しておきます。

```Music.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Music extends Model
{
    protected $table = 'musics';

    protected $guarded = [
        'm_id',
        'name',
        'e',
        'n',
        'h',
        'exh',
        'fc'
    ];
}
```

```Clear.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Clear extends Model
{
    protected $table = 'clears';
    protected $primaryKey = 'id';

    //仕方ないのでidをサロゲート（代理）キーに

    protected function rules(){
        return [
            'id' => 'integer',
            'm_id' => 'integer',
            'u_id' => 'integer',
            'info' => 'integer'
        ];
    }

    protected $fillable=[
        'id',
        'm_id',
        'u_id',
        'info'
    ];
}
```
ここに各要素のバリデーションのルールやらレコードの更新設定とかをするわけですね。
###トラブル
Clear.phpの中に`//仕方ないのでidをサロゲートキーに`というコメントがあります。
実は、はじめにClearモデルを作成した際はidカラムは存在せず、m_idとu_idの複合主キーだけでした。

しかし、いざレコードをsaveメソッドで保存しようとすると、エラー。

どうやら複合主キーを使うとsaveメソッドが使えなくなるらしい。なんやそれ。

まあ、メソッドをオーバーライドすればできないこともないらしい。~~メンドクセー~~
###参考
>https://qiita.com/wrbss/items/7245103a5fef88cbdde9
>https://github.com/laravel/framework/issues/5517

仕方がないので代理キーとして**id**を追加しました。

##ビュー
**laravel**での開発と銘打っているし見た目にはそこまで拘りません。

resources/viewsフォルダにlayoutsフォルダ、estimatorフォルダを作成し、
layoutsにはおおもとのレイアウト、estimatorではそれを継承した具体的なビューを管理します。

```/layouts/estimator.blade.php
<html>
    <head>
        <title>@yield('title')</title>
        <style>
body {font-size:16pt; color:#708090; margin: 5px;}

h1.title{
 position: relative;
  padding: 0.2em 0.5em;
  background: -webkit-linear-gradient(to right, rgb(255, 124, 111), #ffc994);
  background: linear-gradient(to right, rgb(255, 124, 111), #ffc994);
  color: white;
  font-weight: lighter;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.56);
}
#logout{font-size:20pt; text-align:right; color:#f6f6f6;
    margin:-20px 0px -30px 0px; letter-spacing:-4pt;}
ul{font-size:12pt;}
td{font-size:16pt;}
hr{margin:25px 100px; border-top: 1px dashed #ddd;}
.menutitle{font-size:14pt; font-weight:bold; margin: 0px;}
.content{margin:10px;}
.footer{text-align:right; font-size:10pt; margin:10px;
    border-bottom:solid 1px #ccc; color:#ccc;}
.blink {
  animation: blinkAnimeA 0.1s infinite alternate;
}
@keyframes blinkAnimeA{
   0% { background: #4dffff }
  95% { background: #ffff1a }
 100% { background: #ffff1a }
}
.highlighted{
color: #0000ff;
text-decoration: underline;
}
.blinkchr {
  animation: blinkAnimeB 0.6s infinite alternate;
}
@keyframes blinkAnimeB{
   0% { color: #ff0000 }
  97% { color: rgba(255, 255, 255, 0.99) }
 100% { color: rgba(255, 255, 255, 0.99) }
}
.fixed_btn
{
  font-size: 1.5em;
  width: 30%;
  height: 10%;
  position: fixed;
  bottom: 10px;
  right: 10px;
  padding: 6px 40px;

  display: inline-block;
  padding: 0.5em 1em;
  text-decoration: none;
  background: #668ad8;/*ボタン色*/
  color: #FFF;
  border-bottom: solid 4px #627295;
  border-radius: 3px;
}
.fixed_btn:active{
  /*ボタンを押したとき*/
  -webkit-transform: translateY(4px);
  transform: translateY(4px);/*下に動く*/
  border-bottom: none;/*線を消す*/
}
        </style>
    </head>
    <body>
        <h1 class='title'>@yield('title')</h1>
        <h2>@yield('loginfo')</h2>
        <hr size="1">
        <div class="content">
            @yield('content')
        </div>
        <div class="footer">
            @yield('footer')
        </div>
    </body>
</html>
```
ここにはめ込むビューが以下

```estimator/index.blade.php
@extends('layouts.estimator')
@section('title','地力Estimator')
@section('loginfo')
    @php//@parent
    @endphp
@if(Auth::check())
<p>DJ NAME:{{$user->name}}</p>
<p id='logout'><a href='/logout'>ログアウト</a></p>
@else
    <p>ログインしていません(<a href='/login'>ログイン</a>|<a href='/register'>登録</a>)</p>
@endif
@endsection

@section('content')
<form action='/' method='post'>
<center>
<button type='submit' class='fixed_btn'>record</button>
<!--推定値-->
<h1 class='suiteichi'>
@if($jiriki==-100&&!Auth::check())
推定値を取得するためにはログインしてください
@elseif($jiriki==-100&&Auth::check())
推定値を取得するためには、チェックボックスにチェックを入れたあとに、右下のボタンを押下してください
@else
推定地力:{{$jiriki}}
@endif
</h1>
<table border = '2'>
<tr><th>曲名</th><th>NO PLAY</th><th>EASY</th><th>NORMAL</th><th>HARD</th><th>EX-HARD</th><th>FULLCOMBO</th></tr>
{{csrf_field()}}
<!--<input type='submit' value='Estimate'>-->
@foreach($musics as $item)

@if(!isset($clears))
@php
$check=-1
@endphp
@else
@php
$check=$clears->where('m_id',$item->m_id)->first()->info
@endphp
@endif

    <tr>
        <!--<td>{{$item->name}}</td>-->

        <td
        @if($check==1)
        bgcolor='#98fb98'
        @elseif($check==2)
        bgcolor='#87cefa'
        @elseif($check==3)
        bgcolor='#ff6347'
        @elseif($check==4)
        bgcolor='#ffff00'
        @elseif($check==5)
        class='blink'
        @endif
        >{{$item->name}}</td>

        <td bgcolor='#a9a9a9'>
        <label><input type="radio" name="{{$item->m_id}}" value=0
        checked
        >NULL</input></label>
        </td>

        <td bgcolor='#98fb98'>
        <label
        @if($item->e <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=1
        @if($check==1)
        checked
        @endif
        >{{round($item->e,7)}}</input></label>
        </td>

        <td bgcolor='#87cefa'>
        <label
        @if($item->n <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=2
        @if($check==2)
        checked
        @endif
        >{{round($item->n,7)}}</input></label>
        </td></label>

        <td bgcolor='#ff6347'>
        <label
        @if($item->h <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=3
        @if($check==3)
        checked
        @endif
        >{{round($item->h,7)}}</input></label>
        </td>

        <td bgcolor='#ffff00'>
        <label
        @if($item->exh <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=4
        @if($check==4)
        checked
        @endif
        >{{round($item->exh,7)}}</input></label>
        </td>

        <td class='blink'>
        <label
        @if($item->fc <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=5
        @if($check==5)
        checked
        @endif
        >{{round($item->fc,7)}}</input></label>
        </td>

    </tr>
@endforeach
</table>
</center>
</form>
@endsection

@section('footer')
copyright 2019 Okada Hibiki
@endsection
```

今回は、主要なページが一つだけだったこともあり、ロジックをビュー側に少し任せすぎてしまったかなというのが反省点。

##コントローラ
```estimatorController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Music;
use App\Clear;
use Illuminate\Support\Facades\Auth;

class estimatorController extends Controller
{
    public function index(Request $request){
        $user = Auth::user();
        $musics = Music::all();

        if(Auth::check()){
            $clears = Clear::where('u_id',$user->id)->get();///getをかかないとダメ！！！
            if(Clear::where('m_id',1)->where('u_id',$user->id)->count()==0){
                foreach($musics as $music){
                    $clear = new Clear;
                    $clear->m_id = $music->m_id;
                    $clear->u_id = $user->id;
                    $clear->info = 0;
                    $clear->save();
                }
            }
        }
        else{
            $clears=null;
        }

        if($request->method()=='POST'){
            if(Auth::check()){
                foreach($musics as $music){
                    $clear = Clear::where('m_id',$music->m_id)->where('u_id',$user->id)->first();
                    $m_id = $music->m_id;
                    $clear->info = (int)$request->$m_id;
                    //$clear->timestamps = false;
                    $clear->save();
                }
            }
            else{
                //
            }
        }

        if(Auth::check()){
            $clears = Clear::where('u_id',$user->id)->get();
            $jiriki = array();
            foreach($clears as $clear){
                switch($clear->info){
                case 0:
                    break;
                case 1:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->e);
                    break;
                case 2:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->n);
                    break;
                case 3:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->h);
                    break;
                case 4:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->exh);
                    break;
                case 5:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->fc);
                    break;
                }
            }
        }
//ここに推定値の処理
        if(!isset($jiriki)){$jiriki=-100;}
        elseif(count($jiriki)==0){$jiriki=-100;}
        else{
            rsort($jiriki);
            $num=ceil(count($jiriki)*0.3);
            $sum=0;
            for($i=0;$i<$num;$i++){
                $sum+=$jiriki[$i];
            }
            $sum=$sum/$num;
            $jiriki=$sum;
        }

        $param = ['musics' => $musics,'user' => $user,'clears' => $clears,'jiriki' => $jiriki];
        return view('estimator.index',$param);
    }
    public function logout(){
        Auth::logout();
        return redirect()->action('estimatorController@index');
    }
}
```
注意点としては、モデルの利用の際にuseしなきゃいけないことくらいか。

処理の概要としては、ログインがあるかをチェックして、しているのであればデータベースをチェック。そのユーザのクリア情報があれば取得し、なければNO PLAYとしたレコードをclearsテーブルに挿入。

POSTメソッドでのアクセスであれば、それを元にクリア情報を更新。

さらに、最新のクリア情報から自力を計算します。

この地力の計算が困ったもので、それらしい確率分布を適用しても、地力低めなEASYクリアとかが増えると地力が下がる、という現象がどうしても起こってしまう。

ので、クリアした楽曲の地力上位３割の平均値を推定地力としています。こっちのほうがシンプルでいいし、さっきみたいな不具合も幾らか少なくなるので良い。

##Auth
Laravelには標準で認証機能があります。
これがものすごく便利で、`php artisan ui vue --auth`するだけでログイン機能が実装できます。
Laravelのバージョン5では`php artisan make:auth`で(読んだ本はこっちだった)
6系ではそれが使えない点に注意してください。

今回のアプリケーションでは、トップページの閲覧にログインは必須ではないが、データの保存や推定値の表示にはログインが必須であるような実装にしました。

そして、標準のAuthを利用すると、**/register**,**/login**,**/home**といったpathが用意されます。

初期状態だと、/registerで登録、/loginでログイン後、/homeにリダイレクトされる仕様だったのですが、これを/にリダイレクトするよう変更します。

この処理はAuth内のコントローラに記述されています。
**/app/Http/Controllers/Auth/**以下の**RegisterController.php**,**LoginController.php**内の
`protected $redirectTo = '/home';`
という記述を
`protected $redirectTo = '/';`
に変更すればOK。

##ルーティング
```routes/web.php
<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', 'estimatorController@index');
Route::post('/','estimatorController@index');
Route::get('/logout','estimatorController@logout');

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');
```

ルーティングとはいえどメインは/で動作するestimatorControllerだけなのでシンプルですね。
#おわりに
フルスタックのフレームワークを使ったのは初めてですが、簡単に自分にとって実用的なアプリが作れました。

正直今回のアプリならSPAと相性が良さそうなので、気が向いたらそれもつくってみようかな(**たぶんやらん**)

###参考文献
>David Skler(2017)初めてのPHP
>掌田津耶乃(2017)PHPフレームワーク Laravel入門