LoginSignup
8
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-04

Laravel #2 Advent Calendar 20195日目のの記事です。

概要・動機

beatmania IIDXというゲームが好きです(唐突)

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

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

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

できたアプリはこんなかんじ↓
スクリーンショット 2019-12-03 15.30.42.png
(開発期間:2日)

クリアできそうな楽曲も教えてくれます!!(目玉機能)

github

MVCそれぞれの概要

モデル

正直ココが全てを決める気がする。

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

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

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

このせいで後に苦労する羽目になるのだが(後述)
空白の ERD.png

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

値はこちらを参考にさせていただきました。

単純にページをコピペすると、それぞれの楽曲は改行(\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クリアとかが増えると地力が下がる、という現象がどうしても起こってしまう。

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

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入門

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1