import { nextTick, onScopeDispose, ref } from 'vue'; import { toast } from 'vue-sonner'; import { useRoomEmotes } from '@/composables/useRoomEmotes'; import { store as throwPaperBallRoute } from '@/routes/room/emote/paper-ball'; import type { PaperBallChargeLevel, PaperBallFlight, PaperBallKnockback, PaperBallThrowError, PaperBallThrownPayload, PaperBallTossOptions, } from '@/types/emote'; const PAPER_BALL_FLIGHT_DURATION = 780; const SEEN_EVENT_RETENTION = 5_000; const THROW_BUTTON_COOLDOWN = 550; const MAX_KNOCKBACK_DISTANCE = 72; const KNOCKBACK_RETURN_SPEED = 58; const KNOCKBACK_FORCE: Record = { 0: 0, 1: 18, 2: 32, 3: 48, }; export function usePaperBallToss({ roomId, currentPlayerId, board, }: PaperBallTossOptions) { const activePaperBalls = ref([]); const hitPlayerIds = ref([]); const paperBallKnockbacks = ref>({}); const throwingAtPlayerId = ref(null); const seenPaperBallIds = new Set(); const timers = new Set(); let knockbackAnimationFrame: number | null = null; let previousKnockbackFrameTime: number | null = null; function schedule(callback: () => void, delay: number): void { const timer = window.setTimeout(() => { timers.delete(timer); callback(); }, delay); timers.add(timer); } function animateKnockbacks(timestamp: number): void { const elapsed = Math.min( timestamp - (previousKnockbackFrameTime ?? timestamp), 32, ); const returnDistance = (KNOCKBACK_RETURN_SPEED * elapsed) / 1_000; const nextKnockbacks: Record = {}; Object.entries(paperBallKnockbacks.value).forEach( ([playerId, knockback]) => { const distance = Math.hypot(knockback.x, knockback.y); const nextDistance = Math.max(0, distance - returnDistance); if (nextDistance > 0.1) { const scale = nextDistance / distance; nextKnockbacks[Number(playerId)] = { x: knockback.x * scale, y: knockback.y * scale, }; } }, ); paperBallKnockbacks.value = nextKnockbacks; previousKnockbackFrameTime = timestamp; if (Object.keys(nextKnockbacks).length > 0) { knockbackAnimationFrame = window.requestAnimationFrame(animateKnockbacks); return; } knockbackAnimationFrame = null; previousKnockbackFrameTime = null; } function applyKnockback( playerId: number, directionX: number, directionY: number, chargeLevel: PaperBallChargeLevel, ): void { const force = KNOCKBACK_FORCE[chargeLevel]; if (force === 0) { return; } const currentKnockback = paperBallKnockbacks.value[playerId] ?? { x: 0, y: 0, }; let nextX = currentKnockback.x + directionX * force; let nextY = currentKnockback.y + directionY * force; const nextDistance = Math.hypot(nextX, nextY); if (nextDistance > MAX_KNOCKBACK_DISTANCE) { const scale = MAX_KNOCKBACK_DISTANCE / nextDistance; nextX *= scale; nextY *= scale; } paperBallKnockbacks.value = { ...paperBallKnockbacks.value, [playerId]: { x: nextX, y: nextY }, }; if (knockbackAnimationFrame === null) { previousKnockbackFrameTime = null; knockbackAnimationFrame = window.requestAnimationFrame(animateKnockbacks); } } async function throwPaperBall( targetPlayerId: number, chargePercentage = 0, ): Promise { if ( targetPlayerId === currentPlayerId() || throwingAtPlayerId.value !== null ) { return; } const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute('content'); if (!csrfToken) { toast.error('Der Papierknäuel konnte nicht geworfen werden.'); return; } throwingAtPlayerId.value = targetPlayerId; try { const response = await fetch(throwPaperBallRoute(roomId()).url, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify({ target_player_id: targetPlayerId, charge_percentage: chargePercentage, }), }); if (response.redirected) { toast.error( 'Bitte lade den Raum neu und versuche es noch einmal.', ); return; } if (!response.ok) { const error = (await response .json() .catch(() => null)) as PaperBallThrowError | null; const message = error?.errors?.target_player_id?.[0] ?? error?.message ?? 'Der Papierknäuel konnte nicht geworfen werden.'; toast.error(message); } } catch { toast.error('Der Papierknäuel konnte nicht geworfen werden.'); } finally { schedule(() => { if (throwingAtPlayerId.value === targetPlayerId) { throwingAtPlayerId.value = null; } }, THROW_BUTTON_COOLDOWN); } } async function animatePaperBall( payload: PaperBallThrownPayload, ): Promise { if ( typeof payload.id !== 'string' || !Number.isInteger(payload.from_player_id) || !Number.isInteger(payload.target_player_id) || !Number.isInteger(payload.charge_level) || payload.charge_level < 0 || payload.charge_level > 3 || seenPaperBallIds.has(payload.id) ) { return; } seenPaperBallIds.add(payload.id); schedule( () => seenPaperBallIds.delete(payload.id), SEEN_EVENT_RETENTION, ); await nextTick(); const gameBoard = board.value; const fromAvatar = gameBoard?.querySelector( `[data-player-avatar="${payload.from_player_id}"]`, ); const targetAvatar = gameBoard?.querySelector( `[data-player-avatar="${payload.target_player_id}"]`, ); if (!gameBoard || !fromAvatar || !targetAvatar) { return; } const boardRect = gameBoard.getBoundingClientRect(); const fromRect = fromAvatar.getBoundingClientRect(); const targetRect = targetAvatar.getBoundingClientRect(); const startX = fromRect.left + fromRect.width / 2 - boardRect.left; const startY = fromRect.top + fromRect.height / 2 - boardRect.top; const travelX = targetRect.left + targetRect.width / 2 - boardRect.left - startX; const travelY = targetRect.top + targetRect.height / 2 - boardRect.top - startY; const distance = Math.hypot(travelX, travelY); const arcHeight = Math.min(130, Math.max(55, distance * 0.22)); const knockbackDirectionX = distance === 0 ? 1 : travelX / distance; const knockbackDirectionY = distance === 0 ? 0 : travelY / distance; activePaperBalls.value.push({ id: payload.id, chargeLevel: payload.charge_level, style: { left: `${startX}px`, top: `${startY}px`, '--travel-quarter-x': `${travelX * 0.25}px`, '--travel-quarter-y': `${travelY * 0.25 - arcHeight * 0.75}px`, '--travel-mid-x': `${travelX * 0.5}px`, '--travel-mid-y': `${travelY * 0.5 - arcHeight}px`, '--travel-three-quarter-x': `${travelX * 0.75}px`, '--travel-three-quarter-y': `${travelY * 0.75 - arcHeight * 0.75}px`, '--travel-x': `${travelX}px`, '--travel-y': `${travelY}px`, }, }); schedule(() => { if (!hitPlayerIds.value.includes(payload.target_player_id)) { hitPlayerIds.value.push(payload.target_player_id); } applyKnockback( payload.target_player_id, knockbackDirectionX, knockbackDirectionY, payload.charge_level, ); }, PAPER_BALL_FLIGHT_DURATION - 80); schedule(() => { activePaperBalls.value = activePaperBalls.value.filter( (paperBall) => paperBall.id !== payload.id, ); }, PAPER_BALL_FLIGHT_DURATION + 40); schedule(() => { hitPlayerIds.value = hitPlayerIds.value.filter( (playerId) => playerId !== payload.target_player_id, ); }, PAPER_BALL_FLIGHT_DURATION + 420); } useRoomEmotes(roomId, animatePaperBall); onScopeDispose(() => { timers.forEach((timer) => window.clearTimeout(timer)); timers.clear(); if (knockbackAnimationFrame !== null) { window.cancelAnimationFrame(knockbackAnimationFrame); } }); return { activePaperBalls, hitPlayerIds, paperBallKnockbacks, throwingAtPlayerId, throwPaperBall, }; }