From fd22b126e768f0affe007926a541d2ffa84e560f Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 19:51:13 +0000 Subject: [PATCH] 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 --- frontend/src/components/FightViewer.vue | 79 +++++++++++++++++++++++++ frontend/src/pages/FightPage.vue | 54 +++++++++++++++++ server/src/routes/fights.ts | 32 ++++++++++ 3 files changed, 165 insertions(+) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 6f7b45b..0d9cf25 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -70,6 +70,51 @@ const glitching = ref(false) const soundOn = ref(true) const insanityMode = ref(false) +// Reactions +const REACTION_EMOJIS: Record = { + fist: '\u{1F44A}', + fire: '\u{1F525}', + skull: '\u{1F480}', + '100': '\u{1F4AF}', + clown: '\u{1F921}', +} +const reactionCounts = ref>({}) +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) { + 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() { + +
+ {{ fe.emoji }} +
+
{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}
+ + +
+ +
@@ -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: ''; diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 7bb6175..998adf2 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -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 = { + 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() {
+ +
+ {{ fe.emoji }} +
+
+ +
+ {{ fe.emoji }} +
+
() +// Track reactions per fight +const VALID_REACTIONS = new Set(['fist', 'fire', 'skull', '100', 'clown']) +const fightReactions = new Map>() + export function getSpectatorCount(fightId: string): number { return spectatorCounts.get(fightId) || 0 } @@ -383,3 +387,31 @@ fightsRouter.get('/:id/stream', (c) => { } }) }) + +// React to a fight +fightsRouter.post('/:id/react', async (c) => { + const fightId = c.req.param('id') + const body = await c.req.json<{ emoji?: string }>() + const emoji = body?.emoji + + if (!emoji || !VALID_REACTIONS.has(emoji)) { + return c.json({ error: 'Invalid reaction. Use: fist, fire, skull, 100, clown' }, 400) + } + + // Aggregate + if (!fightReactions.has(fightId)) { + fightReactions.set(fightId, {}) + } + const counts = fightReactions.get(fightId)! + counts[emoji] = (counts[emoji] || 0) + 1 + + // Broadcast via SSE + fightEvents.emit({ + fightId, + type: 'reaction', + data: { emoji, counts: { ...counts } }, + timestamp: new Date().toISOString(), + }) + + return c.json({ ok: true, counts }) +})