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;
|
@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