diff --git a/frontend/public/assets/mascots/dog_point.webp b/frontend/public/assets/mascots/dog_point.webp new file mode 100644 index 0000000..d1a755e Binary files /dev/null and b/frontend/public/assets/mascots/dog_point.webp differ diff --git a/frontend/public/assets/mascots/lion_point.webp b/frontend/public/assets/mascots/lion_point.webp new file mode 100644 index 0000000..321af4e Binary files /dev/null and b/frontend/public/assets/mascots/lion_point.webp differ diff --git a/frontend/public/assets/mascots/tiger_point.webp b/frontend/public/assets/mascots/tiger_point.webp new file mode 100644 index 0000000..ccf9469 Binary files /dev/null and b/frontend/public/assets/mascots/tiger_point.webp differ diff --git a/frontend/public/assets/mascots/wave/bull_down.webp b/frontend/public/assets/mascots/wave/bull_down.webp new file mode 100644 index 0000000..a1f63d0 Binary files /dev/null and b/frontend/public/assets/mascots/wave/bull_down.webp differ diff --git a/frontend/public/assets/mascots/wave/bull_up.webp b/frontend/public/assets/mascots/wave/bull_up.webp new file mode 100644 index 0000000..c8ea052 Binary files /dev/null and b/frontend/public/assets/mascots/wave/bull_up.webp differ diff --git a/frontend/public/assets/mascots/wave/eagle_down.webp b/frontend/public/assets/mascots/wave/eagle_down.webp new file mode 100644 index 0000000..8112e8e Binary files /dev/null and b/frontend/public/assets/mascots/wave/eagle_down.webp differ diff --git a/frontend/public/assets/mascots/wave/eagle_up.webp b/frontend/public/assets/mascots/wave/eagle_up.webp new file mode 100644 index 0000000..4060a74 Binary files /dev/null and b/frontend/public/assets/mascots/wave/eagle_up.webp differ diff --git a/frontend/public/assets/mascots/wave/horse_down.webp b/frontend/public/assets/mascots/wave/horse_down.webp new file mode 100644 index 0000000..e710fc2 Binary files /dev/null and b/frontend/public/assets/mascots/wave/horse_down.webp differ diff --git a/frontend/public/assets/mascots/wave/horse_up.webp b/frontend/public/assets/mascots/wave/horse_up.webp new file mode 100644 index 0000000..48bbb72 Binary files /dev/null and b/frontend/public/assets/mascots/wave/horse_up.webp differ diff --git a/frontend/public/assets/mascots/wave/parrot_down.webp b/frontend/public/assets/mascots/wave/parrot_down.webp new file mode 100644 index 0000000..9b76aae Binary files /dev/null and b/frontend/public/assets/mascots/wave/parrot_down.webp differ diff --git a/frontend/public/assets/mascots/wave/parrot_up.webp b/frontend/public/assets/mascots/wave/parrot_up.webp new file mode 100644 index 0000000..7f5afb4 Binary files /dev/null and b/frontend/public/assets/mascots/wave/parrot_up.webp differ diff --git a/frontend/public/assets/mascots/wave/rooster_down.webp b/frontend/public/assets/mascots/wave/rooster_down.webp new file mode 100644 index 0000000..3dd8680 Binary files /dev/null and b/frontend/public/assets/mascots/wave/rooster_down.webp differ diff --git a/frontend/public/assets/mascots/wave/rooster_up.webp b/frontend/public/assets/mascots/wave/rooster_up.webp new file mode 100644 index 0000000..30ae3f5 Binary files /dev/null and b/frontend/public/assets/mascots/wave/rooster_up.webp differ diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index ddccfde..63237e7 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -21,9 +21,50 @@ import { import Countdown from "./Countdown"; import RankingBoard from "./RankingBoard"; import TeamFlag from "./TeamFlag"; +import TalkingMascot from "./TalkingMascot"; type Step = "form" | "done"; +// 투표하기 버튼 양옆 캐릭터 말풍선 — 투표 유도(코믹). +// 왼쪽 시바견(👉 버튼 가리킴) / 오른쪽 호랑이(👈 버튼 가리킴). +const DOG_LINES = [ + "여기 눌러서 투표! 👉", + "AI 이길 자신 있어? 😏", + "고민은 짧게, 픽은 빠르게", + "스코어 예측 가즈아 ⚽", + "투표 안 하면 후회함 ㅋㅋ", + "직감 믿고 ㄱㄱ!", + "딱 한 번이면 돼, 찍어봐", + "AI 셋 다 발라버리자 🔥", + "1분이면 충분해, ㄱㄱ", + "네 예측이 궁금해 👀", +]; +const TIGER_LINES = [ + "👈 여기서 투표하는 거야", + "한국 몇 대 몇? 찍어봐! 🇰🇷", + "투표하고 AI랑 붙자 🔥", + "네 픽 보여줘 봐!", + "정확히 맞히면 네가 레전드 🏆", + "망설이면 마감된다 ⏰", + "감으로라도 일단 ㄱㄱ", + "예측 한 방에 인생역전? ㅋㅋ", + "한국 이긴다에 한 표! 🇰🇷", + "스코어까지 맞혀봐 🎯", +]; +// AI 박스 사자 — AI 예측에 대한 멘트. +const LION_LINES = [ + "AI 셋이 다 다르게 봤네 🤔", + "GPT·클로드·제미나이… 누가 맞을까?", + "내 직감은 AI보다 정확함 😼", + "데이터로 보면 말이지…", + "예측은 예측일 뿐, 공은 둥글다 ⚽", + "AI도 가끔 틀린다구 ㅋㅋ", + "이 확률, 믿어? 말아?", + "분석 결과는… 일단 비밀 🤫", + "통계는 통계일 뿐이지 📊", + "이변은 항상 있더라고 👀", +]; + // 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨) function fmtSubmitted(iso: string): string { const m = iso.match(/\d{4}-(\d{2})-(\d{2})/); @@ -58,6 +99,10 @@ export default function Arena({ const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; const leftShort = flip ? bShort : aShort; const rightShort = flip ? aShort : bShort; + // 마스코트(사자·버튼 양옆 캐릭터)는 한국-남아공 경기에서만 노출. + const isKorRsa = + [match.teamA.code, match.teamB.code].includes("KOR") && + [match.teamA.code, match.teamB.code].includes("RSA"); const [outcome, setOutcome] = useState("DRAW"); const [scoreA, setScoreA] = useState(0); const [scoreB, setScoreB] = useState(0); @@ -184,7 +229,18 @@ export default function Arena({ )} {/* ===== 레이어드 화이트 시트 (002) ===== */} -
+
+ {/* AI 박스 오른쪽 위 사자 — 한국-남아공전 전용 */} + {isKorRsa && ( +
+ +
+ )}

{t.aiBattle}

@@ -366,13 +422,36 @@ export default function Arena({ {error && (

{error}

)} - + {/* 투표하기 버튼 — 한국-남아공전에서만 양옆에 한국·일본 캐릭터(탭 시 투표 말풍선) */} +
+ {isKorRsa && ( + + )} + + {isKorRsa && ( + + )} +
)} diff --git a/frontend/src/components/MatchupHUD.tsx b/frontend/src/components/MatchupHUD.tsx index f874931..915ff88 100644 --- a/frontend/src/components/MatchupHUD.tsx +++ b/frontend/src/components/MatchupHUD.tsx @@ -3,6 +3,7 @@ import { kickoffDisplay } from "@/lib/format"; import { type Lang, dict, teamShort } from "@/lib/i18n"; import BallIcon from "./BallIcon"; import TeamFlag from "./TeamFlag"; +import WaveStrip from "./WaveStrip"; export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) { const t = dict(lang); @@ -14,13 +15,23 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang? const right = flip ? match.teamA : match.teamB; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; + // 마스코트(파도타기 등)는 한국-남아공 경기에서만 노출. + const codes = [match.teamA.code, match.teamB.code]; + const isKorRsa = codes.includes("KOR") && codes.includes("RSA"); return (
{/* 대결 카드 (녹색 글로우 보더) */}
+ {/* 박스 오른쪽 상단 파도타기 (응원 5마리) — 한국-남아공전 전용 */} + {isKorRsa && ( +
+ +
+ )} + {/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
diff --git a/frontend/src/components/TalkingMascot.tsx b/frontend/src/components/TalkingMascot.tsx new file mode 100644 index 0000000..fabd5ec --- /dev/null +++ b/frontend/src/components/TalkingMascot.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// 탭하면 랜덤 말풍선이 뜨는 마스코트. draggable 이면 끌어서 옮길 수도 있다. +const BUBBLE_MS = 2600; +const TAP_THRESHOLD = 6; + +function Bubble({ text, below }: { text: string; below?: boolean }) { + const box = ( +
+ {text} +
+ ); + const tail =
; + return below ? ( +
+
{tail}
+ {box} +
+ ) : ( +
+ {box} +
{tail}
+
+ ); +} + +export default function TalkingMascot({ + src, + lines, + className = "", + bubbleBelow = false, + draggable = false, + auto = false, + autoDelayMs = 0, + autoIntervalMs = 6000, +}: { + src: string; + lines: string[]; + className?: string; + bubbleBelow?: boolean; + draggable?: boolean; + auto?: boolean; + autoDelayMs?: number; + autoIntervalMs?: number; +}) { + const [bubble, setBubble] = useState(null); + const [off, setOff] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const lastLine = useRef(""); + const timer = useRef | undefined>(undefined); + const start = useRef({ px: 0, py: 0, ox: 0, oy: 0, moved: false }); + + useEffect(() => () => clearTimeout(timer.current), []); + + const speak = useCallback(() => { + let line = lines[Math.floor(Math.random() * lines.length)]; + if (lines.length > 1) + while (line === lastLine.current) + line = lines[Math.floor(Math.random() * lines.length)]; + lastLine.current = line; + setBubble(line); + clearTimeout(timer.current); + timer.current = setTimeout(() => setBubble(null), BUBBLE_MS); + }, [lines]); + + // 자동 모드: 클릭 없이 일정 간격으로 말풍선 (두 캐릭터에 offset 주면 번갈아 나옴) + useEffect(() => { + if (!auto) return; + let interval: ReturnType | undefined; + const start = setTimeout(() => { + speak(); + interval = setInterval(speak, autoIntervalMs); + }, autoDelayMs); + return () => { + clearTimeout(start); + if (interval) clearInterval(interval); + }; + }, [auto, autoDelayMs, autoIntervalMs, speak]); + + // --- 드래그 모드 --- + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!draggable) return; + e.preventDefault(); + start.current = { px: e.clientX, py: e.clientY, ox: off.x, oy: off.y, moved: false }; + setDragging(true); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [draggable, off], + ); + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!draggable || !dragging) return; + const dx = e.clientX - start.current.px; + const dy = e.clientY - start.current.py; + if (Math.abs(dx) > TAP_THRESHOLD || Math.abs(dy) > TAP_THRESHOLD) + start.current.moved = true; + setOff({ x: start.current.ox + dx, y: start.current.oy + dy }); + }, + [draggable, dragging], + ); + const onPointerUp = useCallback(() => { + if (!draggable) return; + setDragging(false); + if (!start.current.moved) speak(); + }, [draggable, speak]); + + return ( +
+ {bubble && } + setDragging(false)} + className={className} + style={{ + cursor: draggable ? (dragging ? "grabbing" : "grab") : auto ? "default" : "pointer", + touchAction: draggable ? "none" : "manipulation", + }} + /> +
+ ); +} diff --git a/frontend/src/components/WaveStrip.tsx b/frontend/src/components/WaveStrip.tsx new file mode 100644 index 0000000..ed18f31 --- /dev/null +++ b/frontend/src/components/WaveStrip.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// 페이지 맨 위 파도타기 — 5마리가 순차로 만세(팔 올림)했다 내린다. +// 5마리는 한 그룹으로 묶여 함께 끌어 옮길 수 있고, 각 캐릭터를 탭하면 +// 남아공전 응원 말풍선이 뜬다. +type Animal = { name: string; lines: string[] }; + +const WAVE: Animal[] = [ + { + name: "bull", + lines: [ + "한국, 남아공 잡고 16강 가자! 🇰🇷", + "남아공 수비를 뚫어라! 🐂", + "오늘 이기면 16강이 보인다", + "대~한민국! 짝짝짝 👏", + "투우사도 한국 응원한다 ⚽", + "스페인도 한국 편이다 🇪🇸", + "남아공 골키퍼 긴장해라!", + "한 골이 16강을 가른다", + "투지로 밀어붙여! 💪", + "끝까지 간다, 대한민국!", + ], + }, + { + name: "eagle", + lines: [ + "남아공전 무조건 이긴다 🦅", + "한국 16강 가즈아 🇰🇷", + "남아공 골문을 노려라!", + "이 경기 잡으면 토너먼트행 ✈️", + "골! 골! 대한민국! ⚽", + "아르헨도 한국 편 🇦🇷", + "역습 한 방이면 끝난다", + "오늘 클린시트 가자 🧤", + "남아공 수비 흔들어!", + "16강에서 또 보자 ✈️", + ], + }, + { + name: "rooster", + lines: [ + "꼬끼오~ 한국 남아공 이겨라! 🐓", + "16강 길목, 오늘이 중요해", + "남아공 막고 골 넣자!", + "대한민국 파이팅 🇰🇷", + "프랑스도 한국 편이다 🙌", + "전반부터 몰아쳐라 🐓", + "남아공 김빠지게 골 넣자", + "한국 미드필드 최고지", + "오늘 분위기 우리 거야", + "꼬끼오! 승리의 함성 📣", + ], + }, + { + name: "parrot", + lines: [ + "삼바 응원! 한국 남아공 격파 🥁", + "골 넣고 16강 가자 💃", + "남아공 수비 흔들어! 🔥", + "대한민국 가즈아 🇰🇷", + "오늘 승점 3점 챙기자 ⚽", + "리듬 타고 골 가자 🎶", + "남아공? 삼바로 제압! 💃", + "한 골 한 골 쌓아가자 ⚽", + "응원석도 파도타기 🌊", + "오늘 MVP는 대한민국 🏆", + ], + }, + { + name: "horse", + lines: [ + "달려라 대한민국, 남아공 잡자! 🏇", + "16강 가는 길, 오늘이 분수령", + "남아공 골문 열어라 ⚽", + "끝까지 응원! 한국 화이팅 📣", + "호날두도 한국 응원한다 🐴", + "측면 돌파로 무너뜨려!", + "스피드로 남아공 제쳐라 🏇", + "한 점 차도 이기면 돼", + "16강 티켓 가져오자 🎫", + "포르투갈도 함께 외친다 🇵🇹", + ], + }, +]; + +const STEP_MS = 150; +const REST = 1; +const BUBBLE_MS = 2600; +const TAP_THRESHOLD = 6; + +export default function WaveStrip() { + const [active, setActive] = useState(-1); + const [bubble, setBubble] = useState<{ i: number; text: string } | null>(null); + const [off, setOff] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const lastLine = useRef(""); + const timer = useRef | undefined>(undefined); + const drag = useRef({ px: 0, py: 0, ox: 0, oy: 0, moved: false, idx: -1 }); + + // 파도타기 루프 + useEffect(() => { + let tick = 0; + const id = setInterval(() => { + tick = (tick + 1) % (WAVE.length + REST); + setActive(tick < WAVE.length ? tick : -1); + }, STEP_MS); + return () => clearInterval(id); + }, []); + useEffect(() => () => clearTimeout(timer.current), []); + + const speak = useCallback((i: number) => { + const lines = WAVE[i].lines; + let line = lines[Math.floor(Math.random() * lines.length)]; + if (lines.length > 1) + while (line === lastLine.current) + line = lines[Math.floor(Math.random() * lines.length)]; + lastLine.current = line; + setBubble({ i, text: line }); + clearTimeout(timer.current); + timer.current = setTimeout(() => setBubble(null), BUBBLE_MS); + }, []); + + // 그룹 전체 드래그 + 개별 탭 구분 + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + const el = (e.target as HTMLElement).closest("[data-idx]"); + const idx = el ? Number((el as HTMLElement).dataset.idx) : -1; + drag.current = { px: e.clientX, py: e.clientY, ox: off.x, oy: off.y, moved: false, idx }; + setDragging(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, + [off], + ); + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging) return; + const dx = e.clientX - drag.current.px; + const dy = e.clientY - drag.current.py; + if (Math.abs(dx) > TAP_THRESHOLD || Math.abs(dy) > TAP_THRESHOLD) + drag.current.moved = true; + setOff({ x: drag.current.ox + dx, y: drag.current.oy + dy }); + }, + [dragging], + ); + const onPointerUp = useCallback(() => { + setDragging(false); + if (!drag.current.moved && drag.current.idx >= 0) speak(drag.current.idx); + }, [speak]); + + return ( +
setDragging(false)} + className="flex items-end justify-end gap-0" + style={{ + transform: `translate(${off.x}px, ${off.y}px) scale(${dragging ? 1.04 : 1})`, + transition: dragging ? "none" : "transform 0.15s ease", + cursor: dragging ? "grabbing" : "grab", + touchAction: "none", + position: "relative", + zIndex: dragging || bubble ? 50 : 1, + }} + > + {WAVE.map((a, i) => { + const up = active === i; + return ( +
+ {bubble?.i === i && ( +
+
+ {bubble.text} +
+
+
+ )} + +
+ ); + })} +
+ ); +}