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:
co-authored by
Claude Opus 4.6
parent
baf153b115
commit
fd22b126e7
@@ -70,6 +70,51 @@ const glitching = ref(false)
|
||||
const soundOn = ref(true)
|
||||
const insanityMode = ref(false)
|
||||
|
||||
// Reactions
|
||||
const REACTION_EMOJIS: Record<string, string> = {
|
||||
fist: '\u{1F44A}',
|
||||
fire: '\u{1F525}',
|
||||
skull: '\u{1F480}',
|
||||
'100': '\u{1F4AF}',
|
||||
clown: '\u{1F921}',
|
||||
}
|
||||
const reactionCounts = ref<Record<string, number>>({})
|
||||
const floatingEmojis = ref<{ id: number; emoji: string; x: number }[]>([])
|
||||
let emojiIdCounter = 0
|
||||
|
||||
async function sendReaction(key: string) {
|
||||
// Spawn local particle immediately
|
||||
spawnFloatingEmoji(REACTION_EMOJIS[key])
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${props.fight.id}/react`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emoji: key }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.counts) reactionCounts.value = data.counts
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function spawnFloatingEmoji(emoji: string) {
|
||||
const id = emojiIdCounter++
|
||||
const x = 15 + Math.random() * 70
|
||||
floatingEmojis.value.push({ id, emoji, x })
|
||||
setTimeout(() => {
|
||||
floatingEmojis.value = floatingEmojis.value.filter(e => e.id !== id)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function receiveReaction(emoji: string, counts: Record<string, number>) {
|
||||
reactionCounts.value = counts
|
||||
spawnFloatingEmoji(REACTION_EMOJIS[emoji] || emoji)
|
||||
}
|
||||
|
||||
defineExpose({ receiveReaction })
|
||||
|
||||
async function toggleSound() {
|
||||
soundOn.value = !soundOn.value
|
||||
await ensureAudioContext()
|
||||
@@ -652,6 +697,16 @@ async function _doReplay() {
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Floating reaction emojis -->
|
||||
<div
|
||||
v-for="fe in floatingEmojis"
|
||||
: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 hit text -->
|
||||
<Transition name="hit-pop">
|
||||
<div v-if="hitTextVisible"
|
||||
@@ -705,6 +760,20 @@ async function _doReplay() {
|
||||
</button>
|
||||
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reaction bar -->
|
||||
<div class="px-3 py-1.5 border-t border-border flex-shrink-0 flex items-center gap-1.5 bg-surface-raised/30">
|
||||
<button
|
||||
v-for="(emoji, key) in REACTION_EMOJIS"
|
||||
:key="key"
|
||||
class="flex items-center gap-0.5 px-2 py-1 rounded-md border border-border/30
|
||||
hover:border-neon-cyan/30 hover:bg-neon-cyan/5 transition-all active:scale-90"
|
||||
@click="sendReaction(key)"
|
||||
>
|
||||
<span class="text-sm">{{ emoji }}</span>
|
||||
<span v-if="reactionCounts[key]" class="font-mono text-[9px] text-text-muted">{{ reactionCounts[key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -837,6 +906,16 @@ async function _doReplay() {
|
||||
100% { filter: none; transform: none; }
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
}
|
||||
|
||||
/* VHS scanline overlay on canvas */
|
||||
.glitch-container::after {
|
||||
content: '';
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user