From 1fd357cf958fbe99d7c59821c44b707f9893ef4b Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 13 Feb 2026 11:35:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/flight/page.tsx | 118 ++++++++++++++++++++++++++ src/app/globals.css | 25 ++++++ src/components/FlightBackground.tsx | 126 ++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 src/app/flight/page.tsx create mode 100644 src/components/FlightBackground.tsx diff --git a/src/app/flight/page.tsx b/src/app/flight/page.tsx new file mode 100644 index 0000000..22a2a36 --- /dev/null +++ b/src/app/flight/page.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { getCurrentVoyage, saveCurrentVoyage, getPreferences } from '@/lib/store'; +import { Voyage } from '@/types'; +import FlightBackground from '@/components/FlightBackground'; + +export default function FlightPage() { + const router = useRouter(); + const [voyage, setVoyage] = useState(null); + const [timeLeft, setTimeLeft] = useState(0); // seconds + const [isPaused, setIsPaused] = useState(false); + const [hideSeconds, setHideSeconds] = useState(false); + + const endTimeRef = useRef(0); + + useEffect(() => { + const current = getCurrentVoyage(); + if (!current || current.status !== 'in_progress') { + router.replace('/'); + return; + } + setVoyage(current); + + const now = Date.now(); + const target = current.startedAt + (current.durationMinutes * 60 * 1000); + endTimeRef.current = target; + + const remainingMs = target - now; + setTimeLeft(Math.max(0, Math.ceil(remainingMs / 1000))); + + const prefs = getPreferences(); + setHideSeconds(prefs.hideSeconds); + }, [router]); + + useEffect(() => { + if (!voyage || isPaused) return; + + const interval = setInterval(() => { + const now = Date.now(); + const diff = endTimeRef.current - now; + + if (diff <= 0) { + setTimeLeft(0); + clearInterval(interval); + } else { + setTimeLeft(Math.ceil(diff / 1000)); + } + }, 1000); + + return () => clearInterval(interval); + }, [voyage, isPaused]); + + const handlePauseToggle = () => { + if (isPaused) { + endTimeRef.current = Date.now() + (timeLeft * 1000); + setIsPaused(false); + } else { + setIsPaused(true); + } + }; + + const handleFinish = () => { + if (!voyage) return; + const ended: Voyage = { ...voyage, endedAt: Date.now() }; + saveCurrentVoyage(ended); + router.push('/debrief'); + }; + + if (!voyage) return null; + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (hideSeconds) return `${m}m`; + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + }; + + return ( +
+ + + {/* UI Element 1: Label */} +
+ + {voyage.routeName} · {isPaused ? '일시정지' : '순항 중'} + +
+ + {/* UI Element 2: Timer */} +
+ {formatTime(timeLeft)} +
+ + {/* UI Element 3: Mission */} +
+ “{voyage.missionText}” +
+ + {/* UI Element 4 & 5: Controls */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 7ce28e7..672fe9a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -123,3 +123,28 @@ @apply bg-background text-foreground; } } + +@layer utilities { + @keyframes twinkle { + 0%, 100% { opacity: 0.8; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(0.8); } + } + .animate-twinkle { + animation: twinkle 4s ease-in-out infinite; + } + .animate-twinkle-delay-1 { + animation: twinkle 5s ease-in-out infinite; + animation-delay: 1s; + } + .animate-twinkle-delay-2 { + animation: twinkle 6s ease-in-out infinite; + animation-delay: 2s; + } + + @media (prefers-reduced-motion: reduce) { + .animate-twinkle, .animate-twinkle-delay-1, .animate-twinkle-delay-2 { + animation: none; + opacity: 0.8; + } + } +} \ No newline at end of file diff --git a/src/components/FlightBackground.tsx b/src/components/FlightBackground.tsx new file mode 100644 index 0000000..982ae69 --- /dev/null +++ b/src/components/FlightBackground.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +export default function FlightBackground() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let width = window.innerWidth; + let height = window.innerHeight; + + // Check prefers-reduced-motion + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const setSize = () => { + width = window.innerWidth; + height = window.innerHeight; + canvas.width = width; + canvas.height = height; + }; + setSize(); + window.addEventListener('resize', setSize); + + // Star configuration + const starCount = 150; + const stars: { x: number; y: number; z: number; speed: number }[] = []; + + // Target point (slightly above center) + // We will move stars away from this point. + // Instead of full 3D, let's do a 2D split flow. + // Stars spawn randomly. + // If x < center, move left-down. + // If x > center, move right-down. + + // Initial Spawn + for (let i = 0; i < starCount; i++) { + stars.push({ + x: Math.random() * width, + y: Math.random() * height, + z: Math.random() * 2 + 0.5, // depth/size + speed: Math.random() * 2 + 0.5 + }); + } + + let animationFrameId: number; + + const render = () => { + if (!ctx) return; + ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; // Trails + ctx.fillRect(0, 0, width, height); + // ctx.clearRect(0, 0, width, height); // Clear + + const targetX = width / 2; + // targetY is virtual, above screen. + // Movement vector: + // Left side: dx = -speed, dy = speed + // Right side: dx = speed, dy = speed + + ctx.fillStyle = '#ffffff'; + + stars.forEach((star) => { + // Reduced motion: very slow or static + const moveFactor = prefersReduced ? 0.1 : 1.0; + + // Determine direction based on side + const isLeft = star.x < targetX; + + // Simple perspective-ish flow + // The further from center X, the faster dx + // The further down, the faster dy + + // Let's stick to the PRD requirements: + // Left -> Bottom-Left + // Right -> Bottom-Right + + const dx = (isLeft ? -1 : 1) * star.speed * 0.5 * moveFactor; + const dy = star.speed * moveFactor; + + star.x += dx; + star.y += dy; + + // Reset if out of bounds + if (star.y > height || star.x < 0 || star.x > width) { + star.y = -10; + star.x = Math.random() * width; + // Bias respawn near center-top for "flow" effect? + // Or just random top. Random top is safer for uniform coverage. + } + + const size = Math.max(0.5, star.z * (star.y / height) * 2); + const opacity = Math.min(1, star.y / 200); // Fade in from top + + ctx.globalAlpha = opacity; + ctx.beginPath(); + ctx.arc(star.x, star.y, size, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1.0; + }); + + // Draw subtle cockpit frame or vignette + // Gradient vignette + const gradient = ctx.createRadialGradient(width/2, height/2, height/3, width/2, height/2, height); + gradient.addColorStop(0, 'transparent'); + gradient.addColorStop(1, 'rgba(0,0,0,0.8)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + animationFrameId = requestAnimationFrame(render); + }; + + render(); + + return () => { + window.removeEventListener('resize', setSize); + cancelAnimationFrame(animationFrameId); + }; + }, []); + + return ; +}