学習の備忘録です。タスク管理アプリを作っています。
今回は、スタンプを獲得していって10個貯まった場合、ごほうびチケットを獲得する処理を書いていきます。
スタンプを獲得するアニメーション演出については以下の記事にまとめていますので、そちらも参考にしていただければ幸いです。
本記事は個人の学習記録です。内容の正確性を保証するものではありませんので、あらかじめご了承ください。
やりたいこと
今までに、夜中にバッチ処理を行い、次にアプリを開いた時にスタンプを獲得するかどうか判定する処理を作っています。
そこから、以下のような処理を追加します。
- スタンプ獲得時、合計スタンプが10個になったらごほうびチケットを獲得
- 獲得スタンプのカウントを0にする
- ごほうびチケット獲得のアニメーション演出をする
バックエンドの流れは以下の通り
- user_statsテーブルのreward_ticketsを+1(ごほうびチケット)
- user_statsテーブルのperfect_stamp_countをリセット(0にする)
フロントエンドの流れは以下の通りです。
- スタンプカード自体が発光してフェードアウト
- チケットを表示
- モーダルを閉じる
こんな感じでごほうびチケット獲得処理を実装していきます。
コントローラーの改修
改修前の該当処理は以下です。
//スタンプ獲得
public function confirmStamp(Request $request) {
//ログインユーザー取得
$user = User::findByToken($request);
//未読スタンプを取得
$unreadStamps = StampHistory::where('user_id',$user->id)
->where('is_read', false)
->count();
if ($unreadStamps > 0) {
try {
DB::beginTransaction();
$user->userStat->increment('perfect_stamp_count', $unreadStamps);
StampHistory::where('user_id', $user->id)
->where('is_read', false)
->update(['is_read' => true]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['success' => false], 500);
}
}
return response()->json(['success' => true]);
}
これに対して、やりたいことを照らし合わせると、
- 獲得スタンプが10個かどうか判定するため、incrementでカウントを増やした後のperfect_stamp_countの値を取得する
- ごほうびチケットを取得するかどうかの判定のためのフラグを挟む
(jQueryでのアニメーション演出用) - スタンプが10個貯まった場合のif分岐をする
- if分岐の中でスタンプのカウントを0に、ごほうびチケットの枚数を+1する
こんな感じで改修が必要です。
ということで以下の感じで改修します。
//スタンプ獲得
public function confirmStamp(Request $request) {
//ログインユーザー取得
$user = User::findByToken($request);
//未読スタンプを取得
$unreadStamps = StampHistory::where('user_id',$user->id)
->where('is_read', false)
->count();
if ($unreadStamps > 0) {
try {
DB::beginTransaction();
$user->userStat->increment('perfect_stamp_count', $unreadStamps);
//新しいスタンプ数を取得
$newStampCount = $user->userStat->fresh()->perfect_stamp_count;
//ごほうびチケットを獲得するかどうかの判定
$gotReward = false;
//10個貯まったらリセット&チケット付与
if ($newStampCount >= 10) {
$user->userStat->update([
'perfect_stamp_count' => 0,
'reward_tickets' => DB::raw('reward_tickets + 1'),
]);
$gotReward = true;
}
StampHistory::where('user_id', $user->id)
->where('is_read', false)
->update(['is_read' => true]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['success' => false], 500);
}
}
return response()->json([
'success' => true,
'gotReward' => $gotReward ?? false,
]);
}
fresh()
変数$newStampCountを作り、スタンプ数を取得していますが、その際に
$newStampCount = $user->userStat->fresh()->perfect_stamp_count;
と書いています。
この間にあるfresh()について、これは「新しく更新された数をデータベースまで取りに行く」という役割があります。
直前のincrementでデータベースの中の値が変化しているので、その変化後の値を改めて確認しに行っています。
もしfresh()がないと、incrementで更新する前の古いスタンプ数を取得してしまい、本当はスタンプが10個貯まってるのにごほうびチケットが獲得できない、みたいなバグが発生してしまいます。
$gotReward
これはjQueryの方に渡して、「ごほうびチケット獲得のアニメーション演出をするかどうか」の判定に使います。
基本的にはfalseのままで、スタンプを10個獲得した時だけtrueにします。
この$gotRewardの結果はjQueryに渡す必要があるので、最後のreturnのところで
‘gotReward’ => $gotReward ?? false,
を渡しています。
?? false……もしも値がNULLならfalseを返すという意味です。
DB::raw(‘reward_tickets + 1’)
DB::rawのrawは「生の」という意味です。
Laravel側で値を組み立てるのではなく、「()の中の文字をそのまま生のSQL命令として直接データベースに入れて!」という命令になります。
つまり、データベース自身に「今持ってる数字に1足しといて!」という命令です。
「’reward_tickets’ => $user->userStat->reward_tickets + 1」でもいいじゃん? と思ったんですが、これだと「競合状態(レースコンディション)」が発生してしまうのであんまりよくないみたいです。
画面を連打されたり、同時にアクセスされたりした時に、データが消える(先祖返りする)ことを指します。
たとえば、チケットを5枚持っているユーザーがいたとして、
- 処理A:スタンプが貯まった時
プログラムが「今5枚だから、+1で6をデータベースに保存しよう」と準備します。 - 処理B:同じタイミングで、ユーザーが別タブで別のチケットを1枚獲得
処理Aがデータベースに保存する前に動き出した場合、処理Bも現在(5枚)のデータを確認し、「今5枚だから、+1で6をデータベースに保存しよう」と準備します。 - 結果:
処理Aが「6」を保存し、その後処理Bも「6」を上書き保存します。
本当は2枚増えて「7枚」になるはずが、古い数字「5」を参照してしまったがために、1枚分の獲得が虚空に消えて「6枚」になってしまいます。
要するに「PHPで計算せず、DB側で加算する」ためにDB::rawを使っています。
jQueryの改修
改修前の該当処理は以下です。
@if($unreadStamps > 0)
<script>
document.addEventListener('DOMContentLoaded', function() {
$('#stamp-card-modal').addClass('is-open');
$('.stamp-animate').addClass('pop');
setTimeout(function() {
$.ajax({
url: '{{ route("stamp.confirm") }}',
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
success: function(data) {
console.log('Ajax成功:', data);
console.log('newCount:', {{ $stampCount + $unreadStamps }});
console.log('stamp-icon要素:', $('.stamp-icon').length);
if (data.success) {
const newCount = {{ $stampCount + $unreadStamps }};
$('.stamp-icon').attr('src', '/images/stamp_' + newCount + '.png');
setTimeout(function() {
$('#stamp-card-modal').removeClass('is-open');
}, 2300);
}
},
error: function(xhr, status, error) {
console.log('Ajax失敗:', error);
// 失敗してもモーダルは閉じる(ユーザーを操作不能にしない)
setTimeout(function() {
$('#stamp-card-modal').removeClass('is-open');
}, 2300);
}
});
}, 800);
});
</script>
@endif
これに対して、
- $gotRewardがtrueだった場合のif分岐を作る
- スタンプカードが光ってごほうびチケットに代わる演出をする
こんな感じで改修が必要です。
ということで以下の感じで改修します。
@if($unreadStamps > 0)
<script>
document.addEventListener('DOMContentLoaded', function() {
$('#stamp-card-modal').addClass('is-open');
$('.stamp-animate').addClass('pop');
setTimeout(function() {
$.ajax({
url: '{{ route("stamp.confirm") }}',
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
success: function(data) {
console.log('Ajax成功:', data);
console.log('newCount:', {{ $stampCount + $unreadStamps }});
console.log('stamp-icon要素:', $('.stamp-icon').length);
if (data.gotReward) {
//チケット獲得演出
setTimeout(function() {
playRewardAnimation();
}, 300);
} else {
const newCount = {{ $stampCount + $unreadStamps }};
$('.stamp-icon').attr('src', '{{ asset('images/') }}' + 'stamp_' + newCount + '.png');
setTimeout(function() {
$('#stamp-card-modal').removeClass('is-open');
}, 1800);
}
},
error: function(xhr, status, error) {
console.log('Ajax失敗:', error);
// 失敗してもモーダルは閉じる(ユーザーを操作不能にしない)
setTimeout(function() {
$('#stamp-card-modal').removeClass('is-open');
}, 1800);
}
});
}, 300);
});
//ごほうびチケット獲得のアニメーション演出
function playRewardAnimation() {
// スタンプカード全体を光らせながらフェードアウト
$('.stamp-modal-content').addClass('card-fadeout');
setTimeout(function() {
// チケット画像を差し込む
$('#stamp-card-modal').append(`
<img id="reward-ticket" class="reward-ticket" src="{{ asset('images/reward_ticket.png') }}" alt="ごほうびチケット">
`);
$('#reward-ticket').addClass('ticket-appear');
// アイコンを0枚にリセット
$('.stamp-icon').attr('src', '{{ asset('images/stamp_0.png') }}');
// モーダルを閉じてリセット
setTimeout(function() {
$('#stamp-card-modal').removeClass('is-open');
setTimeout(function() {
$('#reward-ticket').remove();
$('.stamp-modal-content').removeClass('card-fadeout');
// スタンプマスを全てリセット
$('.stamp-item').each(function() {
$(this).find('.stamp-mark').remove();
});
}, 500);
}, 1800);
}, 600);
}
</script>
@endif
if (data.gotReward)
ここで、サーバーから返ってきたgotRewardがtrueの場合の処理を書いていきます。
長くなるのでplayRewardAnimation()を分けて作って呼び出します。
ごほうびチケット獲得のアニメーション演出
playRewardAnimation()でスタンプカードをフェードアウトさせつつチケットを表示させます。
全体の流れは以下。
- スタンプカード全体を光らせながらフェードアウト
- チケット画像を差し込む
- チケットをぼよんぼよん跳ねさせるアニメーション用のクラスを追加
※$(‘#reward-ticket’).addClass(‘ticket-appear’); - トップ画面のスタンプカードアイコンをstamp_0.pngにする
- モーダルを閉じる
- 追加したごほうびチケット表示用の要素(#reward-ticket)を削除
- card-fadeoutクラスを削除
- スタンプマスを全てリセット
ちなみにごほうびチケットはとりあえずCanvaのチケットテンプレートの文字を変えて作っています。

$(‘.stamp-modal-content’).addClass(‘card-fadeout’);
ここでスタンプカードの全体を光らせながらフェードアウトさせ、setTimeoutでちょっと遅らせながらチケット画像を差し込みます。
stamp-modal-contentのさらに外側の要素stamp-card-modalの中にimgタグをアペンドしています。
JavaScriptのテンプレートリテラル内であっても、Bladeはサーバー側で先に展開されるため、asset()を利用できます。
注意点として、モーダルを閉じる時にスタンプマスを全てリセットしなければ、画面更新しない限り次にモーダルを開いた時のスタンプ数が10個のままになっています。
(データベースの更新は済んでいるが、見た目上のリセットが効いていない)
よって、jQueryの処理の中で、しっかりstamp-markクラスを削除していきます。
ごほうびチケット獲得のCSS追加
jQueryでアニメーション演出用に追加したreward-ticketとかticket-appearとかを調整していきます。
/*ごほうびチケット獲得のcss*/
/* スタンプカード全体が光りながらフェードアウト */
.card-fadeout {
animation: cardFadeout 0.8s ease-in forwards;
}
@keyframes cardFadeout {
0% { opacity: 1; filter: brightness(1); }
50% { opacity: 0.8; filter: brightness(2); }
100% { opacity: 0; filter: brightness(3); }
}
/* チケット画像 */
.reward-ticket {
width: 60%;
opacity: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.8);
}
.reward-ticket.ticket-appear {
animation: ticketAppear 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97) forwards;
}
@keyframes ticketAppear {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
60% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
filter: brightness()
対象を明るくしたり暗くしたりするフィルターです。以下に参考サイトのリンクを貼ります。
これでスタンプカードが光った感じにします。
@keyframes(キーフレーム)
指定されたアニメーションの動きを調整します。
たとえば@keyframes cardFadeoutは、直前に記述した
animation: cardFadeout 0.8s ease-in forwards;
の動きを調整しています。
cardFadeoutは0.8秒で完結するアニメーションなので、
- 0%の時(開始) ……opacity(不透明度)は1でfilter: brightness(1)
- 50%の時(0.4秒後) ……opacity(不透明度)は0.8でfilter: brightness(2)
※完了時間の半分が過ぎた段階でほぼ消えている&明るさが2倍になっている - 100%の時(完了) ……opacity(不透明度)は0でfilter: brightness(3)
まとめ
上記の改修をして、以下の感じでアニメーションを実装できました。
なんかスタンプカードが発光するというより白んで消える感じなので、もっとビカビカ光る感じにしたいですね。
とりあえず、スタンプ10個貯まったらごほうびチケットを獲得&アニメーション演出の実装までは完了しましたので、一応はOKです!



コメント