STAFF BLOG

Laravel 5 Events and Listeners for points and badges gamification system (Part 2: Badges)

Example code for Laravel 5.2 Events and Listeners. English tutorial.

Habi*doはいろんなゲーミフィケーションの要素を積極的に取り入れ、より楽しく、シンプルに使えるエンゲージメントツールです。次はその中のポイントとバッジを例にして、近年爆発的な人気を誇るlaravel(PHPのフレームワーク)のイベント(Even)とリスナー(Listener)を使って、ゲーミフィケーションの要素を実現する方法を簡単に紹介したいと思います。実際の計算方法はもっと複雑で、独自のアルゴリズムを使っていますが、仕組みだけを説明します。

今回はそのPart2として、バッジの仕組みについてです。

This is “Part 2: Badges”, covering the implementation of a little more complex Badge system using Events and Listeners.

In “Part 1: Points” we covered how to implement a simple Points system with Events and Listeners.

Prerequisite

Setup Continuation

Tables

Badges Table
Table Seeder for Badge Table
Badge User Table

Models

Model for Badges

Events

LikeClickedEvent

Listeners

LikeBadgeListener

EventServiceProvider

Controllers

NewsfeedController

Prerequisite

Read and follow Part 1.

Setup Continuation

Tables

For this entry we have stripped the tables to the bare minimum columns. You will probably have further columns and pivot tables.

Badges Table

(※Depending on how many badges you have you could also hard code these in a Class, but for extensibility we will use a table)

In this case each badge can have 4 levels (1,2,3,legend) and we will create one row for each Badge.
Level 1,2 and 3 (called normal) will use the same image, so only one badge image path is needed.
The legend badge has a different colour and hence a different path to the badge image needs to be saved.
A grey path column exist for the image of the silhouette of the badge.


Schema::create('badges', function (Blueprint $table) {
           $table->increments('id');
           $table->string('badge_name',20)->index(); //badge system internal name
           $table->string('normal_path')->nullable();   //path to level 1,2,3 badge image
           $table->string('ledgend_path')->nullable(); //path to legend badge image
           $table->string('grey_path')->nullable();       //path to silhouette image of badge
       });

 

Table Seeder for Badge Table (database/seeds/BadgeTableSeeder.php)

Let us seed three badges into the Badges table. Of course you would want to have more.
Note the order as not to get the id’s mixed up which are used in the badge_user pivot table
(Alternatively use the badge names as identifier if you prefer).
Also find 9 pictures (three for each badge) and set them in in a new folder called badges the public images file.
Eg.  public/images/badges/normal_dailyhabit.png


<?php

use Illuminate\Database\Seeder;
use App\Model\Badges\Badge;

class BadgeTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        
        /*
         * Badge ID 1: Likable
         */
        $badge = new Badge();
        $badge->badge_name = 'likable';
        $badge->normal_path = 'images/badges/normal_likable.png';
        $badge->ledgend_path = 'images/badges/ledgend_likable.png';
        $badge->grey_path = 'images/badges/grey_likable.png';
        $badge->save();
        
        /*
         * Badge ID 2: Daily Habit
         */
        $badge = new Badge();
        $badge->badge_name = 'dailyhabit';
        $badge->normal_path = 'images/badges/normal_dailyhabit.png';
        $badge->ledgend_path = 'images/badges/ledgend_dailyhabit.png';
        $badge->grey_path = 'images/badges/grey_dailyhabit.png';
        $badge->save();
        
        /*
         * Badge ID 3: Prolific God
         */
        $badge = new Badge();
        $badge->badge_name = 'prolific';
        $badge->normal_path = 'images/badges/normal_prolific.png';
        $badge->ledgend_path = 'images/badges/ledgend_prolific.png';
        $badge->grey_path = 'images/badges/grey_prolific.png';
        $badge->save();
                
    }
}

Badge User Table

The pivot table between badges and users will also contain the information on the state of each user’s badges.


Schema::create('badge_user', function (Blueprint $table) {
           $table->integer('user_id')->unsigned()->index();    //user id
           $table->integer('badge_id')->unsigned();               //badge id
           $table->integer('criteria_count')->unsigned()->default(0);   //count total of x;
           $table->integer('streak_count')->unsigned()->default(0);    //streak count

           $table->dateTime('level_1')->nullable()->default(null); //datetime first badge of level 1.
           $table->dateTime('level_2')->nullable()->default(null); //datetime first badge of level 2.
           $table->dateTime('level_3')->nullable()->default(null); //datetime first badge of level 3.
           $table->dateTime('level_4')->nullable()->default(null); //datetime first badge legend.
          
           $table->timestamps();
           $table->softDeletes();
           
           $table->foreign('user_id')
                   ->references('id')
                   ->on('users')
                   ->onDelete('cascade');
           $table->foreign('badge_id')
                   ->references('id')
                   ->on('badges')
                   ->onDelete('cascade');
           
           $table->unique(['user_id','badge_id','deleted_at']);
                      
       });

 

Explanation of Columns:

  • criteria_count: count total of x. For example: 'criteria_count' is a count of every time users gets a like on one of their posts.
    So if every time users gets a Like this number counts up by 1.
  • streak_count: only for badges that have a streak. For example  'x days in a row'.
    So if the user gets at least one Like on a day this badge counts up by 1. Else it will restart at 0.criteria_count and  streak_count can be used independently for some badges or in combination for other badges.
    Combination example: 10 Likes for 20 days in a row.
    Then criteria_count will count Likes from 0 to 10 each day, while streak_count  will count the days this streak has been going on.

Models

※ Don’t forget the Models from Part 1!

Model for Badges (app/Model/Badges/Badge.php)

<?php

namespace App\Model\Badges;

use Illuminate\Database\Eloquent\Model;
use App\User;
use Carbon\Carbon;
use App\Events\GotBadgeEvent;

class Badge extends Model
{
        
    /*
     * Badge Table Colums
     */
    const COL_ID = 'id';
    const COL_BADGE_NAME = 'badge_name';
    const COL_NORMAL_PATH = 'normal_path';
    const COL_LEDGEND_PATH = 'ledgend_path';
    const COL_GREY_PATH = 'grey_path';
    
    /*
     * Pivot Table Columns
     */
    const COL_USER_ID = 'user_id';
    const COL_BADGE_ID = 'badge_id';
    const COL_CRITERIA = 'criteria_count';
    const COL_STREAK = 'streak_count';
    const COL_LEVEL_1 = 'level_1';
    const COL_LEVEL_2 = 'level_2';
    const COL_LEVEL_3 = 'level_3';
    const COL_LEVEL_4 = 'level_4';
    const COL_UPDATED_AT = 'updated_at';
    
    const TABLE_BADGE_USER = 'badge_user';
    
    /*
     * Possible Levels
     */
    const LOCKED = 'locked';
    const LEVEL_1 = 'level_1';
    const LEVEL_2 = 'level_2';
    const LEVEL_3 = 'level_3';
    const LEVEL_4 = 'level_4';
    
    /*
     * Badge Ids and Criteria
     */    
    const LIKABLE_BADGE_ID = 1;
    const LIKABLE_BADGE_CRITERIA = ['level_1' => 10, 'level_2' => 150, 'level_3' => 800, 'level_4'  => 3000];
    
    const DAILYHABIT_BADGE_ID = 2;
    const DAILYHABIT_BADGE_CRITERIA = ['level_1' => 3, 'level_2' => 10, 'level_3' => 30, 'level_4'  => 200];
    
    const PROFILIC_BADGE_ID = 3;
    const PROFILIC_BADGE_CRITERIA = ['level_1' => [10, 3], 'level_2' => [10,8], 'level_3' => [10,20], 'level_4'  => [10,80]]; //[likes(criteria),days(streak)]
    
    
    
    /**
     * The database table used by the Model.
     *
     * @var string
     */
    protected $table = 'badges';
    
    
    /**
     * Laravel Assumes the model has timestamps as default.
     * Turn off timestamps.
     * @var type 
     */
    public $timestamps = false;
    
    
    /**
     * Check if a new badge should be awarded
     * for the current points in criteria_count or streak_count or both combinined
     * @return type
     */
    public function checkForNewBadge() {
        
        switch($this->id){
            case self::LIKABLE_BADGE_ID:
                return $this->checkSimpleCriteria(self::LIKABLE_BADGE_CRITERIA);    //1
            
            /* Other Badges can be added here.        
            case self::DAILYHABIT_BADGE_ID:
                return $this->checkSimpleStreak(self::DAILYHABIT_BADGE_CRITERIA);   //2
            case self::PROFILIC_BADGE_ID:
                return $this->checkSimpleCriteria(self::PROFILIC_BADGE_CRITERIA);   //3
            */
        }
        
    }
    
    
    /**
     * Check if criteria_count is equal or bigger 
     * to required amounts per level.
     * 
     * @param type $levelArray
     * @return boolean|string
     */
    public function checkSimpleCriteria($levelArray) {
        
        if($this->pivot->criteria_count >= $levelArray[self::LEVEL_4]){
            return self::LEVEL_4;
        }elseif($this->pivot->criteria_count >= $levelArray[self::LEVEL_3]){
            return self::LEVEL_3;
        }elseif($this->pivot->criteria_count >= $levelArray[self::LEVEL_2]){
            return self::LEVEL_2;
        }elseif($this->pivot->criteria_count >= $levelArray[self::LEVEL_1]){
            return self::LEVEL_1;
        }
        
        return false;
    }}
    
    /**
     * Check if streak_count is equal or bigger 
     * to required amounts per level.
     * 
     * @param type $levelArray
     * @return boolean|string
     */
    public function checkSimpleStreak($levelArray) {
        
        if($this->pivot->streak_count >= $levelArray[self::LEVEL_4]){
            return self::LEVEL_4;
        }elseif($this->pivot->streak_count >= $levelArray[self::LEVEL_3]){
            return self::LEVEL_3;
        }elseif($this->pivot->streak_count >= $levelArray[self::LEVEL_2]){
            return self::LEVEL_2;
        }elseif($this->pivot->streak_count >= $levelArray[self::LEVEL_1]){
            return self::LEVEL_1;
        }
        
        return false;
    }
       
    
    /**
     * Get highest Badge level
     */
    public function getHighestBadgeLevel(){
        if(!empty($this->pivot->level_4)){
            return self::COL_LEVEL_4;
        }elseif(!empty($this->pivot->level_3)){
            return self::COL_LEVEL_3;
        }elseif(!empty($this->pivot->level_2)){
            return self::COL_LEVEL_2;
        }elseif(!empty($this->pivot->level_1)){
            return self::COL_LEVEL_1;
        }
        return self::LOCKED;
    }
    
    
    /**
     * Get path for given level image
     * @return type
     */
    public function getBadgePath($badgeLevel){
        if($badgeLevel == self::LEVEL_4){
            return secure_asset($this->ledgend_path);
        }elseif($badgeLevel == self::LOCKED){
            return secure_asset($this->grey_path);
        }
        return secure_asset($this->normal_path);
    }

    
    /**
     * Static function to count up badge criteria and streak count.
     * 
     * @param type $user
     * @param type $badgeId
     * @param type $boxId
     * @param type $instanceId
     * @return boolean
     */
    public static function simpleBadgeCriteriaCountup($user, $badgeId, $addition = 1){
        
        //get the badge with badge_user pivot data.
        $badge = $user->badges()
                ->wherePivot(Badge::COL_BADGE_ID, $badgeId)
                ->withPivot([Badge::COL_CRITERIA, Badge::COL_STREAK, Badge::COL_LEVEL_1, Badge::COL_LEVEL_2, Badge::COL_LEVEL_3, Badge::COL_LEVEL_4])
                ->first();
        
        //if null, make user start collecting for this badge and return out of function
        if(is_null($badge)){
            $user->badges()->attach($badgeId, [Badge::COL_CRITERIA => $addition]);
            return true;
        }
        
        //count up
        $badge->pivot->criteria_count += $addition;
        $user->badges()->updateExistingPivot($badgeId, [Badge::COL_CRITERIA => $badge->pivot->criteria_count]);

        //check if legible for a badge
        $newBadgeLevel = $badge->checkForNewBadge();
        //add to db if got a new level.
        if($newBadgeLevel != false && empty($badge->pivot->$newBadgeLevel)){
            //save new level, 
            $user->badges()->updateExistingPivot($badgeId, [$newBadgeLevel => Carbon::now('UTC')->toDateTimeString()]);
            
            /*
             * Here you could add another event that broadcasts "User A got the XY Badge!" with listeners which will add this information
             * to a notification/newsfeed system or similar. This is not included in this tutorial, but the information here should
             * be enough for you to implement this by yourself.
             * 
             * event(new GotBadgeEvent($user, $badge, $newBadgeLevel));
             * 
             */
            
        }  
        return true;
    }
}


 

Events

LikeClickedEvent (app/Events/LikeClickedEvent.php)

No change from Part 1.

Listeners

LikeBadgeListener (app/Listeners/BadgeListeners/LikeBadgeListener.php)

This listener will handle every event where Like was clicked in a queue.
It will count up the times users got their posts liked.
It will further check if the new total count if eligible for a badge and if so save it to the database.


<?php

namespace App\Listeners\BadgeListeners;

use App\Events\LikeClickedEvent;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Model\User;
use App\Model\Likes\Like;
use App\Model\Badges\Badge;

class LikeBadgeListener implements ShouldQueue
{
    use InteractsWithQueue;


    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  LikeClickedEvent  $event
     * @return void
     */
    public function handle(LikeClickedEvent $event)
    {
        $this->handleLikableBadge($event);
    }
    
    
    
    /**
     * Handle Badge Count for the user receiving a like for a post.
     * 
     * @param LikeClickedEvent $event
     */
    private function handleLikableBadge(LikeClickedEvent $event)
    {
     
        $badgeId = Badge::LIKABLE_BADGE_ID;
        $user = User::find($like->target->user_id);
        
        Badge::simpleBadgeCriteriaCountup($user, $badgeId);
                
    }
}

EventServiceProvider

(※ This File is already part of the Laravel framework. It is normally located at app/Providers/EventServiceProvider.php)

Add the LikeBadgeListener to the Event Service Provider from Part 1.
Together with Part 1 it should look similar to this:


  /**
    * The event listener mappings for the application.
    *
    * @var array
    */
   protected $listen = [
                 'App\Events\LikeClickedEvent' => [
                                'App\Listeners\PointsListeners\LikePointsListeners',
                                'App\Listeners\BadgeListeners\LikeBadgeListeners'
                                ],
                  ];

Controllers

NewsfeedController (app/Http/Controllers/Newsfeed/NewsfeedController.php)

No change from Part 1.

関連記事

ページ上部へ戻る