feat: spectator reactions with floating emoji particles

POST /api/fights/:fightId/react endpoint accepts emoji reactions (fist,
fire, skull, 100, clown), aggregates counts, broadcasts via SSE.
Reaction bar added to FightViewer with floating emoji particles that
rise and fade. Live fight views receive reactions via SSE in real-time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 19:51:13 +00:00
co-authored by Claude Opus 4.6
parent baf153b115
commit fd22b126e7
3 changed files with 165 additions and 0 deletions
+54
View File
@@ -127,6 +127,20 @@ const liveAnnouncement = ref('')
const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const spectatorCount = ref(0)
const liveFloatingEmojis = ref<{ id: number; emoji: string; x: number }[]>([])
let liveEmojiCounter = 0
const EMOJI_MAP: Record<string, string> = {
fist: '\u{1F44A}', fire: '\u{1F525}', skull: '\u{1F480}', '100': '\u{1F4AF}', clown: '\u{1F921}',
}
function spawnLiveReactionEmoji(key: string) {
const emoji = EMOJI_MAP[key] || key
const id = liveEmojiCounter++
const x = 15 + Math.random() * 70
liveFloatingEmojis.value.push({ id, emoji, x })
setTimeout(() => {
liveFloatingEmojis.value = liveFloatingEmojis.value.filter(e => e.id !== id)
}, 2000)
}
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
@@ -394,6 +408,16 @@ function connectSSE() {
} catch { /* ignore */ }
})
eventSource.addEventListener('reaction', (e) => {
try {
const data = JSON.parse(e.data)
if (data.emoji && data.counts) {
// Spawn floating emoji in the live view
spawnLiveReactionEmoji(data.emoji)
}
} catch { /* ignore */ }
})
eventSource.addEventListener('round_start', (e) => {
try {
const data = JSON.parse(e.data)
@@ -1024,6 +1048,16 @@ function stopAutoBattle() {
<div class="flex-1 relative min-h-0">
<canvas ref="liveCanvas" class="w-full h-full block" />
<!-- Floating reaction emojis -->
<div
v-for="fe in liveFloatingEmojis"
:key="fe.id"
class="absolute pointer-events-none z-25 emoji-float"
:style="{ left: `${fe.x}%`, bottom: '10%' }"
>
<span class="text-2xl sm:text-3xl">{{ fe.emoji }}</span>
</div>
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="liveAnnouncementVisible"
@@ -1167,6 +1201,16 @@ function stopAutoBattle() {
<div class="flex-1 relative min-h-0">
<canvas ref="liveCanvas" class="w-full h-full block" />
<!-- Floating reaction emojis -->
<div
v-for="fe in liveFloatingEmojis"
:key="fe.id"
class="absolute pointer-events-none z-25 emoji-float"
:style="{ left: `${fe.x}%`, bottom: '10%' }"
>
<span class="text-2xl sm:text-3xl">{{ fe.emoji }}</span>
</div>
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="liveAnnouncementVisible"
@@ -1342,6 +1386,16 @@ function stopAutoBattle() {
to { transform: scale(1.08) rotate(1deg); }
}
/* Floating reaction emoji */
.emoji-float {
animation: emoji-rise 2s ease-out forwards;
}
@keyframes emoji-rise {
0% { opacity: 1; transform: translateY(0) scale(1); }
50% { opacity: 0.8; transform: translateY(-60px) scale(1.2); }
100% { opacity: 0; transform: translateY(-140px) scale(0.6); }
}
/* Safe area padding for notched phones */
.safe-bottom {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));