feat: 세션 진행화면 생성
This commit is contained in:
118
src/app/flight/page.tsx
Normal file
118
src/app/flight/page.tsx
Normal file
@@ -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<Voyage | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0); // seconds
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [hideSeconds, setHideSeconds] = useState(false);
|
||||
|
||||
const endTimeRef = useRef<number>(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 (
|
||||
<div className="flex flex-col flex-1 items-center justify-center p-6 text-white relative overflow-hidden min-h-[calc(100vh-64px)]">
|
||||
<FlightBackground />
|
||||
|
||||
{/* UI Element 1: Label */}
|
||||
<div className="absolute top-8 text-center z-10">
|
||||
<span className="text-xs font-medium text-indigo-300 uppercase tracking-widest border border-indigo-500/30 bg-indigo-950/50 backdrop-blur px-4 py-1.5 rounded-full shadow-[0_0_15px_rgba(99,102,241,0.3)]">
|
||||
{voyage.routeName} · {isPaused ? '일시정지' : '순항 중'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* UI Element 2: Timer */}
|
||||
<div className={`font-mono font-light tracking-tighter tabular-nums text-7xl md:text-9xl my-12 relative z-10 drop-shadow-2xl transition-opacity duration-300 ${isPaused ? 'opacity-50' : 'opacity-100'}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
|
||||
{/* UI Element 3: Mission */}
|
||||
<div className="text-xl md:text-2xl text-slate-200 text-center max-w-2xl mb-24 font-medium leading-relaxed relative z-10 drop-shadow-md px-4">
|
||||
“{voyage.missionText}”
|
||||
</div>
|
||||
|
||||
{/* UI Element 4 & 5: Controls */}
|
||||
<div className="flex gap-6 absolute bottom-12 z-10">
|
||||
<button
|
||||
onClick={handlePauseToggle}
|
||||
className="px-8 py-3 rounded-full border border-slate-600 bg-slate-900/50 backdrop-blur text-slate-300 hover:text-white hover:border-slate-400 hover:bg-slate-800/80 transition-all text-sm font-bold uppercase tracking-wide"
|
||||
>
|
||||
{isPaused ? '다시 시작' : '일시정지'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
className="px-8 py-3 rounded-full bg-slate-100 text-slate-900 hover:bg-indigo-500 hover:text-white transition-all text-sm font-bold uppercase tracking-wide shadow-lg shadow-white/10"
|
||||
>
|
||||
{timeLeft === 0 ? '도착 (회고)' : '항해 종료'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/components/FlightBackground.tsx
Normal file
126
src/components/FlightBackground.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function FlightBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 <canvas ref={canvasRef} className="fixed inset-0 z-0 pointer-events-none bg-black" />;
|
||||
}
|
||||
Reference in New Issue
Block a user