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 }) +})