refactor: fsd 구조로 변환

This commit is contained in:
2026-02-13 15:20:35 +09:00
parent bb1a6fbdab
commit d60d4ccd9e
45 changed files with 1283 additions and 1222 deletions

View File

@@ -1,10 +1,9 @@
'use client';
import { useState, Suspense } from 'react';
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ROUTES } from '@/lib/constants';
import { saveCurrentVoyage } from '@/lib/store';
import { Voyage } from '@/types';
import { ROUTES } from '@/shared/config/routes';
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
function BoardingContent() {
const router = useRouter();
@@ -12,24 +11,9 @@ function BoardingContent() {
const routeId = searchParams.get('routeId');
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
const [mission, setMission] = useState('');
const [notes, setNotes] = useState('');
const handleDocking = () => {
if (!mission.trim()) return;
const newVoyage: Voyage = {
id: crypto.randomUUID(),
routeId: route.id,
routeName: route.name,
durationMinutes: route.durationMinutes,
startedAt: Date.now(),
status: 'in_progress',
missionText: mission,
notes: notes,
};
saveCurrentVoyage(newVoyage);
const handleDocking = (mission: string) => {
const started = startVoyage({ route, mission });
if (!started) return;
router.push('/flight');
};
@@ -41,41 +25,11 @@ function BoardingContent() {
</div>
<div className="space-y-8 flex-1">
<div className="space-y-3">
<label className="block text-sm font-medium text-slate-300">
</label>
<input
type="text"
value={mission}
onChange={(e) => setMission(e.target.value)}
placeholder="예: 서론 3문단 완성하기"
className="w-full bg-slate-900/50 border-b-2 border-slate-700 focus:border-indigo-500 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600"
autoFocus
<BoardingMissionForm
onDock={handleDocking}
autoFocus={true}
/>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-slate-400">
()
</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="오늘의 컨디션이나 제약사항"
className="w-full bg-transparent border-b border-slate-800 focus:border-slate-500 px-0 py-2 text-base outline-none transition-colors placeholder:text-slate-700 text-slate-300"
/>
</div>
</div>
<button
onClick={handleDocking}
disabled={!mission.trim()}
className="w-full py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold text-lg rounded-xl transition-all shadow-lg shadow-indigo-900/30 mt-8"
>
()
</button>
</div>
);
}

View File

@@ -2,8 +2,8 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/lib/store';
import { Voyage, VoyageStatus } from '@/types';
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types';
export default function DebriefPage() {
const router = useRouter();

View File

@@ -1,118 +1,13 @@
'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';
import { FlightBackgroundWidget } from '@/widgets/flight-background';
import { FlightHudWidget } from '@/widgets/flight-hud';
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 vanishYOffset={-68} centerProtectRadius={200} />
{/* 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">
&ldquo;{voyage.missionText}&rdquo;
</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 className="relative flex min-h-[calc(100vh-64px)] flex-1 flex-col items-center justify-center overflow-hidden p-6 text-white">
<FlightBackgroundWidget />
<FlightHudWidget />
</div>
);
}

View File

@@ -3,8 +3,8 @@
import { useEffect, useState, use } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { getHistory } from '@/lib/store';
import { Voyage } from '@/types';
import { getHistory } from '@/shared/lib/store';
import { Voyage } from '@/shared/types';
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params is a Promise

View File

@@ -2,8 +2,8 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { getHistory } from '@/lib/store';
import { Voyage, VoyageStatus } from '@/types';
import { getHistory } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types';
export default function LogListPage() {
const [logs, setLogs] = useState<Voyage[]>([]);

View File

@@ -1,372 +1,13 @@
"use client";
'use client';
import { ROUTES } from "@/lib/constants";
import { getCurrentVoyage } from "@/lib/store";
import { Route } from "@/types";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CSSProperties, useEffect } from "react";
type Star = {
cx: number;
cy: number;
r: number;
armScale?: number;
};
type Segment = {
x1: number;
y1: number;
x2: number;
y2: number;
};
type Constellation = {
key: "orion" | "auriga" | "ursaMajor";
className: string;
viewBox: string;
colorClass: string;
stars: Star[];
segments: Segment[];
};
const CONSTELLATIONS: Constellation[] = [
{
key: "orion",
className: "absolute bottom-10 left-5 w-72 h-72 opacity-30",
viewBox: "0 0 100 100",
colorClass: "text-indigo-200",
stars: [
{ cx: 25, cy: 15, r: 0.82 },
{ cx: 75, cy: 25, r: 0.96, armScale: 1.08 },
{ cx: 45, cy: 48, r: 0.58 },
{ cx: 50, cy: 50, r: 0.54 },
{ cx: 55, cy: 52, r: 0.6 },
{ cx: 30, cy: 85, r: 0.86 },
{ cx: 70, cy: 80, r: 0.98, armScale: 1.08 },
],
segments: [
{ x1: 25, y1: 15, x2: 45, y2: 48 },
{ x1: 75, y1: 25, x2: 55, y2: 52 },
{ x1: 45, y1: 48, x2: 30, y2: 85 },
{ x1: 55, y1: 52, x2: 70, y2: 80 },
],
},
{
key: "auriga",
className: "absolute top-10 right-10 w-64 h-64 opacity-25",
viewBox: "0 0 100 100",
colorClass: "text-blue-200",
stars: [
{ cx: 50, cy: 15, r: 1.06, armScale: 1.1 },
{ cx: 20, cy: 35, r: 0.67 },
{ cx: 25, cy: 75, r: 0.65 },
{ cx: 75, cy: 75, r: 0.66 },
{ cx: 85, cy: 35, r: 0.76 },
],
segments: [
{ x1: 50, y1: 15, x2: 20, y2: 35 },
{ x1: 20, y1: 35, x2: 25, y2: 75 },
{ x1: 25, y1: 75, x2: 75, y2: 75 },
{ x1: 75, y1: 75, x2: 85, y2: 35 },
{ x1: 85, y1: 35, x2: 50, y2: 15 },
],
},
{
key: "ursaMajor",
className: "absolute top-20 left-10 w-80 h-48 opacity-25",
viewBox: "0 0 100 60",
colorClass: "text-slate-200",
stars: [
{ cx: 10, cy: 20, r: 0.64 },
{ cx: 25, cy: 25, r: 0.67 },
{ cx: 40, cy: 35, r: 0.69 },
{ cx: 55, cy: 45, r: 0.99 },
{ cx: 75, cy: 45, r: 0.98 },
{ cx: 80, cy: 15, r: 0.93 },
{ cx: 60, cy: 10, r: 0.96 },
],
segments: [
{ x1: 10, y1: 20, x2: 25, y2: 25 },
{ x1: 25, y1: 25, x2: 40, y2: 35 },
{ x1: 40, y1: 35, x2: 55, y2: 45 },
{ x1: 55, y1: 45, x2: 75, y2: 45 },
{ x1: 75, y1: 45, x2: 80, y2: 15 },
{ x1: 80, y1: 15, x2: 60, y2: 10 },
{ x1: 60, y1: 10, x2: 55, y2: 45 },
],
},
];
const STAR_STYLES = [
{ duration: 2.3, delay: 0.1 },
{ duration: 3.1, delay: 1.2 },
{ duration: 4.8, delay: 0.8 },
{ duration: 2.7, delay: 2.1 },
{ duration: 5.2, delay: 1.7 },
{ duration: 3.9, delay: 0.4 },
{ duration: 4.4, delay: 2.6 },
{ duration: 2.1, delay: 1.3 },
{ duration: 5.8, delay: 0.2 },
{ duration: 3.3, delay: 2.4 },
{ duration: 4.0, delay: 1.1 },
{ duration: 2.9, delay: 1.9 },
] as const;
function StarGlint({ starIndex, star }: { starIndex: number; star: Star }) {
const timing = STAR_STYLES[starIndex % STAR_STYLES.length];
const strengthTier =
star.r >= 0.95 ? "bright" : star.r >= 0.72 ? "mid" : "faint";
const glintPeak =
strengthTier === "bright" ? 0.7 : strengthTier === "mid" ? 0.61 : 0.52;
const bloomPeak =
strengthTier === "bright" ? 0.16 : strengthTier === "mid" ? 0.12 : 0.08;
const coreLow =
strengthTier === "bright" ? 0.9 : strengthTier === "mid" ? 0.73 : 0.6;
const coreHigh =
strengthTier === "bright" ? 1 : strengthTier === "mid" ? 0.9 : 0.82;
const coreReduced =
strengthTier === "bright" ? 0.9 : strengthTier === "mid" ? 0.76 : 0.66;
const coreStyle = {
animationDuration: `${timing.duration}s`,
animationDelay: `${timing.delay}s`,
"--core-low": `${coreLow}`,
"--core-high": `${coreHigh}`,
"--core-reduced": `${coreReduced}`,
} as CSSProperties;
const glintStyle = {
animationDuration: `${timing.duration}s`,
animationDelay: `${timing.delay + 0.12}s`,
"--glint-peak": `${glintPeak}`,
"--glint-base": "0.02",
"--bloom-peak": `${bloomPeak}`,
"--bloom-base": "0.01",
} as CSSProperties;
const glintLength = star.r * 4.4 * (star.armScale ?? 1);
const gradientXId = `glint-x-${starIndex}`;
const gradientYId = `glint-y-${starIndex}`;
return (
<g>
<defs>
<linearGradient
id={gradientXId}
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient
id={gradientYId}
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<circle
cx={star.cx}
cy={star.cy}
r={star.r}
className="star-core"
style={coreStyle}
/>
<circle
cx={star.cx}
cy={star.cy}
r={star.r * 1.9}
className="star-core-bloom"
style={coreStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint-bloom"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint-bloom"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
</g>
);
}
function LobbyBackground() {
return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
{CONSTELLATIONS.map((constellation, constellationIndex) => (
<div key={constellation.key} className={constellation.className}>
<svg
viewBox={constellation.viewBox}
className={`w-full h-full fill-current ${constellation.colorClass}`}
>
{constellation.segments.map((segment, segmentIndex) => (
<line
key={`${constellation.key}-segment-${segmentIndex}`}
x1={segment.x1}
y1={segment.y1}
x2={segment.x2}
y2={segment.y2}
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
))}
{constellation.stars.map((star, starIndex) => {
const globalStarIndex =
CONSTELLATIONS.slice(0, constellationIndex).reduce(
(sum, item) => sum + item.stars.length,
0,
) + starIndex;
return (
<StarGlint
key={`${constellation.key}-star-${globalStarIndex}`}
starIndex={globalStarIndex}
star={star}
/>
);
})}
</svg>
</div>
))}
</div>
);
}
function RouteCard({
route,
isCTA = false,
}: {
route: Route;
isCTA?: boolean;
}) {
return (
<div
className={`group relative bg-slate-900/60 backdrop-blur-md hover:bg-slate-800/80 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-6 transition-all duration-300 flex flex-col h-full shadow-lg hover:shadow-indigo-900/20 ${isCTA ? "min-h-[200px] justify-center items-center text-center" : ""}`}
>
<div
className={`flex w-full relative z-10 ${isCTA ? "flex-col items-center gap-4" : "justify-between items-start mb-4"}`}
>
<div className={isCTA ? "flex flex-col items-center" : ""}>
<h3
className={`font-bold text-indigo-100 group-hover:text-white transition-colors ${isCTA ? "text-3xl" : "text-xl"}`}
>
{route.name}
</h3>
<span className="text-xs font-medium text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded-full border border-slate-800 mt-2 inline-block">
{route.tag}
</span>
</div>
<span
className={`font-light text-slate-300 group-hover:text-white transition-colors ${isCTA ? "text-4xl mt-2" : "text-3xl"}`}
>
{route.durationMinutes === 0 ? "∞" : route.durationMinutes}
<span className="text-sm ml-1 text-slate-500">
{route.durationMinutes === 0 ? "" : "min"}
</span>
</span>
</div>
{!isCTA && (
<p className="text-sm text-slate-400 mb-8 flex-1 leading-relaxed relative z-10 text-left w-full">
{route.description}
</p>
)}
{isCTA && (
<p className="text-base text-slate-400 mb-6 relative z-10 max-w-lg">
{route.description}
</p>
)}
<Link
href={`/boarding?routeId=${route.id}`}
className={`flex items-center justify-center bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/30 active:scale-[0.98] relative z-10 ${isCTA ? "w-full max-w-md py-4 text-lg" : "w-full py-4"}`}
>
{isCTA ? "정거장 진입 (대기)" : "바로 출항"}
</Link>
</div>
);
}
import { LobbyBackgroundWidget } from '@/widgets/lobby-background';
import { LobbyRoutesPanel } from '@/widgets/lobby-routes';
export default function Home() {
const router = useRouter();
useEffect(() => {
const current = getCurrentVoyage();
if (current && current.status === "in_progress") {
router.replace("/flight");
}
}, [router]);
const stationRoute = ROUTES[0];
const normalRoutes = ROUTES.slice(1);
return (
<div className="flex flex-col flex-1 relative animate-in fade-in duration-500">
<LobbyBackground />
<div className="relative z-10 flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-6 md:p-12">
<div className="space-y-4 text-center mb-12 max-w-2xl mx-auto backdrop-blur-sm bg-slate-950/30 p-6 rounded-3xl border border-slate-800/30">
<h1 className="text-3xl md:text-5xl font-bold text-slate-100 tracking-tight">
?
</h1>
<p className="text-slate-300 text-lg"> .</p>
</div>
<div className="flex flex-col gap-6 w-full max-w-4xl mx-auto">
<div className="w-full">
<RouteCard route={stationRoute} isCTA={true} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
{normalRoutes.map((route) => (
<RouteCard key={route.id} route={route} />
))}
</div>
</div>
</div>
<div className="relative flex flex-1 flex-col animate-in fade-in duration-500">
<LobbyBackgroundWidget />
<LobbyRoutesPanel />
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { getPreferences, savePreferences } from '@/lib/store';
import { getPreferences, savePreferences } from '@/shared/lib/store';
export default function SettingsPage() {
const [hideSeconds, setHideSeconds] = useState(false);

View File

@@ -1,312 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
type FlightBackgroundProps = {
vanishYOffset?: number;
centerProtectRadius?: number;
};
type Star = {
wx: number;
wy: number;
z: number;
speed: number;
radius: number;
alpha: number;
tailLength: number;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const randomInRange = (min: number, max: number) =>
min + Math.random() * (max - min);
export default function FlightBackground({
vanishYOffset = -64,
centerProtectRadius = 190,
}: FlightBackgroundProps) {
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;
let animationFrameId = 0;
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let prefersReduced = motionQuery.matches;
const vanishXJitter = (Math.random() < 0.5 ? -1 : 1) * randomInRange(10, 25);
const setSize = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
};
const getStarCount = () => {
const isMobile = width < 768;
const min = isMobile ? 12 : 18;
const max = isMobile ? 30 : 45;
const byArea = Math.round((width * height) / 42000);
return clamp(byArea, min, max);
};
const getVanishingPoint = () => ({
x: width / 2 + vanishXJitter,
y: height / 2 + vanishYOffset,
});
const createSpeed = () => {
const tier = Math.random();
if (tier < 0.9) return randomInRange(0.003, 0.007);
if (tier < 0.99) return randomInRange(0.007, 0.011);
return randomInRange(0.011, 0.014);
};
const createVisuals = () => {
const highlight = Math.random() < 0.16;
const tailRoll = Math.random();
const tailLength =
tailRoll < 0.82
? randomInRange(0, 2.5)
: tailRoll < 0.86
? randomInRange(2.5, 3.8)
: randomInRange(4, 10);
return {
radius: highlight ? randomInRange(1.2, 1.8) : randomInRange(0.7, 1.2),
alpha: highlight ? randomInRange(0.55, 0.85) : randomInRange(0.25, 0.55),
tailLength,
};
};
const createSpawnRadius = () => {
const roll = Math.random();
const maxWideRadius = Math.min(Math.max(width, height) * 0.7, 360);
const ringOuter = Math.min(320, maxWideRadius);
if (roll < 0.08) {
return randomInRange(0, 60);
}
if (roll < 0.8) {
return randomInRange(80, Math.max(80, ringOuter));
}
return randomInRange(120, Math.max(120, maxWideRadius));
};
const createStar = (isRespawn: boolean): Star => {
const radius = createSpawnRadius();
const angle = randomInRange(0, Math.PI * 2);
const visuals = createVisuals();
return {
wx: Math.cos(angle) * radius,
wy: Math.sin(angle) * radius,
z: isRespawn ? randomInRange(0.9, 1.55) : randomInRange(0.55, 1.6),
speed: createSpeed(),
radius: visuals.radius,
alpha: visuals.alpha,
tailLength: visuals.tailLength,
};
};
setSize();
let stars: Star[] = Array.from({ length: getStarCount() }, () => createStar(false));
const screenDistanceFromCenter = (x: number, y: number) => {
const cx = width / 2;
const cy = height / 2;
return Math.hypot(x - cx, y - cy);
};
const applyCenterProtection = (x: number, y: number, alpha: number) => {
const distance = screenDistanceFromCenter(x, y);
if (distance >= centerProtectRadius) return alpha;
const ratio = clamp(distance / centerProtectRadius, 0, 1);
const attenuation = 0.35 + ratio * 0.65;
return alpha * attenuation;
};
const project = (star: Star, zValue: number) => {
const vp = getVanishingPoint();
return {
x: vp.x + star.wx / zValue,
y: vp.y + star.wy / zValue,
};
};
const recycleStar = (index: number) => {
stars[index] = createStar(true);
};
const drawStar = (
star: Star,
fromX: number,
fromY: number,
toX: number,
toY: number,
) => {
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
const dx = toX - fromX;
const dy = toY - fromY;
const movementLength = Math.hypot(dx, dy);
if (star.tailLength < 1 || movementLength < 0.001) {
ctx.globalAlpha = visibleAlpha;
ctx.fillStyle = '#f8fbff';
ctx.beginPath();
ctx.arc(toX, toY, star.radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
return;
}
const dirX = dx / movementLength;
const dirY = dy / movementLength;
const tailX = toX - dirX * star.tailLength;
const tailY = toY - dirY * star.tailLength;
const gradient = ctx.createLinearGradient(tailX, tailY, toX, toY);
gradient.addColorStop(0, 'rgba(248, 251, 255, 0)');
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
ctx.strokeStyle = gradient;
ctx.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6);
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(toX, toY);
ctx.stroke();
ctx.globalAlpha = Math.min(1, visibleAlpha + 0.08);
ctx.fillStyle = '#f8fbff';
ctx.beginPath();
ctx.arc(toX, toY, clamp(star.radius * 0.72, 0.6, 1.45), 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
};
const drawCenterVeil = () => {
const vp = getVanishingPoint();
const veil = ctx.createRadialGradient(
vp.x,
vp.y,
centerProtectRadius * 0.18,
vp.x,
vp.y,
centerProtectRadius * 1.35,
);
veil.addColorStop(0, 'rgba(160, 185, 235, 0.08)');
veil.addColorStop(0.55, 'rgba(90, 114, 170, 0.03)');
veil.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = veil;
ctx.fillRect(0, 0, width, height);
};
const drawVignette = () => {
const vignette = ctx.createRadialGradient(
width / 2,
height / 2,
Math.min(width, height) * 0.25,
width / 2,
height / 2,
Math.max(width, height) * 0.95,
);
vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
vignette.addColorStop(1, 'rgba(0, 0, 0, 0.82)');
ctx.fillStyle = vignette;
ctx.fillRect(0, 0, width, height);
};
const drawFrame = (moveStars: boolean) => {
ctx.fillStyle = 'rgba(2, 5, 10, 0.3)';
ctx.fillRect(0, 0, width, height);
drawCenterVeil();
stars.forEach((star, index) => {
const from = project(star, star.z);
if (moveStars) {
star.z -= star.speed;
}
const to = project(star, star.z);
const outOfBounds =
to.x < -50 || to.x > width + 50 || to.y < -50 || to.y > height + 50;
if (star.z <= 0.22 || outOfBounds) {
recycleStar(index);
return;
}
drawStar(star, from.x, from.y, to.x, to.y);
});
drawVignette();
};
const render = () => {
drawFrame(true);
animationFrameId = requestAnimationFrame(render);
};
const renderStatic = () => {
ctx.clearRect(0, 0, width, height);
drawFrame(false);
};
const handleResize = () => {
setSize();
stars = Array.from({ length: getStarCount() }, () => createStar(false));
if (prefersReduced) {
renderStatic();
}
};
const handleMotionChange = (event: MediaQueryListEvent) => {
prefersReduced = event.matches;
if (prefersReduced) {
cancelAnimationFrame(animationFrameId);
renderStatic();
return;
}
render();
};
window.addEventListener('resize', handleResize);
motionQuery.addEventListener('change', handleMotionChange);
if (prefersReduced) {
renderStatic();
} else {
render();
}
return () => {
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', handleMotionChange);
cancelAnimationFrame(animationFrameId);
};
}, [vanishYOffset, centerProtectRadius]);
return <canvas ref={canvasRef} className="fixed inset-0 z-0 pointer-events-none bg-black" />;
}

View File

@@ -1,332 +0,0 @@
"use client";
import { useEffect, useState } from "react";
export default function LobbyBackground() {
const [isMount, setIsMount] = useState(false);
useEffect(() => {
setIsMount(true);
}, []);
// Helper for random delay/duration style
const getRandomStyle = () => ({
animationDelay: `${Math.random() * 5}s`,
animationDuration: `${2 + Math.random() * 4}s`,
});
if (!isMount) return <div className="fixed inset-0 z-0 bg-black" />;
return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
{/* Orion - Bottom Left */}
<div className="absolute bottom-10 left-5 w-72 h-72 opacity-30 rotate-[-10deg]">
<svg
viewBox="0 0 100 100"
className="w-full h-full text-indigo-200 fill-current"
>
<circle
cx="250"
cy="15"
r="1.2"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="75"
cy="25"
r="1.2"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="45"
cy="48"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="50"
cy="50"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="55"
cy="52"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="30"
cy="85"
r="1.2"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="70"
cy="80"
r="1.2"
className="animate-twinkle"
style={getRandomStyle()}
/>
<line
x1="25"
y1="15"
x2="45"
y2="48"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="75"
y1="25"
x2="55"
y2="52"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="45"
y1="48"
x2="30"
y2="85"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="55"
y1="52"
x2="70"
y2="80"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
</svg>
</div>
{/* Auriga (마차부자리) - Top Right */}
<div className="absolute top-10 right-10 w-64 h-64 opacity-25 rotate-[15deg]">
<svg
viewBox="0 0 100 100"
className="w-full h-full text-blue-200 fill-current"
>
<circle
cx="50"
cy="15"
r="1.3"
className="animate-twinkle"
style={getRandomStyle()}
/>{" "}
{/* Capella */}
<circle
cx="20"
cy="35"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="25"
cy="75"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="75"
cy="75"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="85"
cy="35"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<line
x1="50"
y1="15"
x2="20"
y2="35"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="20"
y1="35"
x2="25"
y2="75"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="25"
y1="75"
x2="75"
y2="75"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="75"
y1="75"
x2="85"
y2="35"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="85"
y1="35"
x2="50"
y2="15"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
</svg>
</div>
{/* Big Dipper (북두칠성) - Top Left */}
<div className="absolute top-20 left-10 w-80 h-48 opacity-25 rotate-[-5deg]">
<svg
viewBox="0 0 100 60"
className="w-full h-full text-slate-200 fill-current"
>
{/* Handle */}
<circle
cx="10"
cy="20"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="25"
cy="25"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="40"
cy="35"
r="1"
className="animate-twinkle"
style={getRandomStyle()}
/>
{/* Bowl */}
<circle
cx="55"
cy="45"
r="1.1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="75"
cy="45"
r="1.1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="80"
cy="15"
r="1.1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<circle
cx="60"
cy="10"
r="1.1"
className="animate-twinkle"
style={getRandomStyle()}
/>
<line
x1="10"
y1="20"
x2="25"
y2="25"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="25"
y1="25"
x2="40"
y2="35"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="40"
y1="35"
x2="55"
y2="45"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="55"
y1="45"
x2="75"
y2="45"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="75"
y1="45"
x2="80"
y2="15"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="80"
y1="15"
x2="60"
y2="10"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
<line
x1="60"
y1="10"
x2="55"
y2="45"
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
</svg>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/lib/cn"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -4,8 +4,8 @@ import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { cn } from "@/shared/lib/cn";
function Dialog({
...props

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/cn";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/lib/cn"
function Separator({
className,

View File

@@ -0,0 +1,2 @@
export { BoardingMissionForm } from './ui/BoardingMissionForm';
export { startVoyage } from './model/startVoyage';

View File

@@ -0,0 +1,33 @@
import { Route, Voyage } from '@/shared/types';
import { saveCurrentVoyage } from '@/shared/lib/store';
const createVoyageId = () =>
(crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
.replace(/[^a-zA-Z0-9]/g, '')
.slice(0, 16);
export const startVoyage = ({
route,
mission,
}: {
route: Route;
mission: string;
}) => {
const missionText = mission.trim();
if (!missionText) {
return false;
}
const newVoyage: Voyage = {
id: createVoyageId(),
routeId: route.id,
routeName: route.name,
durationMinutes: route.durationMinutes,
startedAt: Date.now(),
status: 'in_progress',
missionText,
};
saveCurrentVoyage(newVoyage);
return true;
};

View File

@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
export function BoardingMissionForm({
onDock,
onCancel,
autoFocus = false,
compact = false,
}: {
onDock: (mission: string) => void;
onCancel?: () => void;
autoFocus?: boolean;
compact?: boolean;
}) {
const [mission, setMission] = useState('');
const trimmedMission = mission.trim();
return (
<div className={`flex flex-col ${compact ? 'gap-6' : 'space-y-8 flex-1'}`}>
<div className="space-y-3">
<label className="block text-sm font-medium text-slate-300">
</label>
<input
type="text"
value={mission}
onChange={(event) => setMission(event.target.value)}
placeholder="예: 서론 3문단 완성하기"
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
autoFocus={autoFocus}
/>
</div>
<div className={`flex ${compact ? 'justify-end gap-3' : 'mt-8 flex-col gap-3'} w-full`}>
{onCancel && (
<button
onClick={onCancel}
className="rounded-xl border border-slate-700 bg-transparent px-4 py-2 font-semibold text-slate-300 transition-colors hover:bg-slate-900/60 hover:text-white"
>
</button>
)}
<button
onClick={() => onDock(trimmedMission)}
disabled={!trimmedMission}
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
>
()
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentVoyage, getPreferences, saveCurrentVoyage } from '@/shared/lib/store';
import { Voyage } from '@/shared/types';
const getVoyageFromStore = () => {
const current = getCurrentVoyage();
if (!current || current.status !== 'in_progress') {
return null;
}
return current;
};
const getEndTime = (voyage: Voyage | null) => {
if (!voyage) return 0;
return voyage.startedAt + voyage.durationMinutes * 60 * 1000;
};
export function useFlightSession() {
const router = useRouter();
const [voyage] = useState<Voyage | null>(() => getVoyageFromStore());
const [timeLeft, setTimeLeft] = useState<number>(() => {
const current = getVoyageFromStore();
const endTime = getEndTime(current);
if (!endTime) return 0;
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
});
const [isPaused, setIsPaused] = useState(false);
const [hideSeconds] = useState(() => getPreferences().hideSeconds);
const endTimeRef = useRef<number>(getEndTime(getVoyageFromStore()));
useEffect(() => {
if (voyage) return;
router.replace('/');
}, [voyage, router]);
useEffect(() => {
if (!voyage || isPaused) return;
const interval = setInterval(() => {
const diff = endTimeRef.current - Date.now();
if (diff <= 0) {
setTimeLeft(0);
clearInterval(interval);
return;
}
setTimeLeft(Math.ceil(diff / 1000));
}, 1000);
return () => clearInterval(interval);
}, [voyage, isPaused]);
const handlePauseToggle = () => {
if (isPaused) {
endTimeRef.current = Date.now() + timeLeft * 1000;
setIsPaused(false);
return;
}
setIsPaused(true);
};
const handleFinish = () => {
if (!voyage) return;
const endedVoyage: Voyage = {
...voyage,
endedAt: Date.now(),
};
saveCurrentVoyage(endedVoyage);
router.push('/debrief');
};
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (hideSeconds) {
return `${minutes}m`;
}
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
};
return {
voyage,
timeLeft,
isPaused,
formattedTime: formatTime(timeLeft),
handlePauseToggle,
handleFinish,
};
}

View File

@@ -0,0 +1 @@
export { FlightStarfieldCanvas } from './ui/FlightStarfieldCanvas';

View File

@@ -0,0 +1,21 @@
import { FlightStar, VanishingPoint } from '@/features/flight-starfield/model/types';
export const createVanishingPoint = ({
width,
height,
xJitter,
yOffset,
}: {
width: number;
height: number;
xJitter: number;
yOffset: number;
}): VanishingPoint => ({
x: width / 2 + xJitter,
y: height / 2 + yOffset,
});
export const projectFlightStar = (star: FlightStar, vp: VanishingPoint, zValue: number) => ({
x: vp.x + star.wx / zValue,
y: vp.y + star.wy / zValue,
});

View File

@@ -0,0 +1,175 @@
import { FLIGHT_STARFIELD_TUNING } from '@/shared/config/starfield';
import { clamp, randomInRange } from '@/shared/lib/math/number';
import { FlightStar } from '@/features/flight-starfield/model/types';
export const getFlightStarCount = (width: number, height: number) => {
const isMobile = width < FLIGHT_STARFIELD_TUNING.mobileBreakpoint;
const min = isMobile
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
const max = isMobile
? FLIGHT_STARFIELD_TUNING.starCount.mobile.max
: FLIGHT_STARFIELD_TUNING.starCount.desktop.max;
const byArea = Math.round((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor);
return clamp(byArea, min, max);
};
export const createFlightVanishXJitter = () => {
const sign = Math.random() < 0.5 ? -1 : 1;
return (
sign *
randomInRange(
FLIGHT_STARFIELD_TUNING.vanishXJitter.min,
FLIGHT_STARFIELD_TUNING.vanishXJitter.max,
)
);
};
const createFlightSpeed = () => {
const tier = Math.random();
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.slow.chance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.speedTiers.slow.min,
FLIGHT_STARFIELD_TUNING.speedTiers.slow.max,
);
}
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.medium.chance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.speedTiers.medium.min,
FLIGHT_STARFIELD_TUNING.speedTiers.medium.max,
);
}
return randomInRange(
FLIGHT_STARFIELD_TUNING.speedTiers.fast.min,
FLIGHT_STARFIELD_TUNING.speedTiers.fast.max,
);
};
const createFlightVisualTier = () => {
const highlight = Math.random() < FLIGHT_STARFIELD_TUNING.radius.highlightChance;
const tailRoll = Math.random();
const tailLength =
tailRoll < FLIGHT_STARFIELD_TUNING.tail.pointChance
? randomInRange(
FLIGHT_STARFIELD_TUNING.tail.pointRange.min,
FLIGHT_STARFIELD_TUNING.tail.pointRange.max,
)
: tailRoll < FLIGHT_STARFIELD_TUNING.tail.shortChance
? randomInRange(
FLIGHT_STARFIELD_TUNING.tail.shortRange.min,
FLIGHT_STARFIELD_TUNING.tail.shortRange.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.tail.longRange.min,
FLIGHT_STARFIELD_TUNING.tail.longRange.max,
);
return {
radius: highlight
? randomInRange(
FLIGHT_STARFIELD_TUNING.radius.highlight.min,
FLIGHT_STARFIELD_TUNING.radius.highlight.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.radius.normal.min,
FLIGHT_STARFIELD_TUNING.radius.normal.max,
),
alpha: highlight
? randomInRange(
FLIGHT_STARFIELD_TUNING.alpha.highlight.min,
FLIGHT_STARFIELD_TUNING.alpha.highlight.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.alpha.normal.min,
FLIGHT_STARFIELD_TUNING.alpha.normal.max,
),
tailLength,
};
};
const createFlightSpawnRadius = (width: number, height: number) => {
const roll = Math.random();
const maxWideRadius = Math.min(
Math.max(width, height) * FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxScaleOfViewport,
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxAbsolute,
);
const ringOuter = Math.min(
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.max,
maxWideRadius,
);
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.centerChance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.min,
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.max,
);
}
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.ringChance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min,
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min, ringOuter),
);
}
return randomInRange(
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min,
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min, maxWideRadius),
);
};
export const createFlightStar = ({
width,
height,
isRespawn,
}: {
width: number;
height: number;
isRespawn: boolean;
}): FlightStar => {
const radius = createFlightSpawnRadius(width, height);
const angle = randomInRange(0, Math.PI * 2);
const visuals = createFlightVisualTier();
return {
wx: Math.cos(angle) * radius,
wy: Math.sin(angle) * radius,
z: isRespawn
? randomInRange(
FLIGHT_STARFIELD_TUNING.zRange.respawn.min,
FLIGHT_STARFIELD_TUNING.zRange.respawn.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.zRange.initial.min,
FLIGHT_STARFIELD_TUNING.zRange.initial.max,
),
speed: createFlightSpeed(),
radius: visuals.radius,
alpha: visuals.alpha,
tailLength: visuals.tailLength,
};
};
export const shouldRecycleFlightStar = ({
x,
y,
z,
width,
height,
}: {
x: number;
y: number;
z: number;
width: number;
height: number;
}) =>
z <= FLIGHT_STARFIELD_TUNING.zRange.recycleThreshold ||
x < -50 ||
x > width + 50 ||
y < -50 ||
y > height + 50;

View File

@@ -0,0 +1,14 @@
export type FlightStar = {
wx: number;
wy: number;
z: number;
speed: number;
radius: number;
alpha: number;
tailLength: number;
};
export type VanishingPoint = {
x: number;
y: number;
};

View File

@@ -0,0 +1,249 @@
'use client';
import { useEffect, useRef } from 'react';
import { projectFlightStar, createVanishingPoint } from '@/features/flight-starfield/lib/projection';
import {
createFlightStar,
createFlightVanishXJitter,
getFlightStarCount,
shouldRecycleFlightStar,
} from '@/features/flight-starfield/model/starfieldModel';
import { FlightStar } from '@/features/flight-starfield/model/types';
import { clamp } from '@/shared/lib/math/number';
import { getPrefersReducedMotionMediaQuery } from '@/shared/lib/motion/prefersReducedMotion';
export function FlightStarfieldCanvas({
vanishYOffset = -68,
centerProtectRadius = 200,
}: {
vanishYOffset?: number;
centerProtectRadius?: number;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
let width = window.innerWidth;
let height = window.innerHeight;
let animationFrameId = 0;
const motionQuery = getPrefersReducedMotionMediaQuery();
let prefersReducedMotion = motionQuery.matches;
const vanishXJitter = createFlightVanishXJitter();
const setCanvasSize = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
};
const getVanishingPoint = () =>
createVanishingPoint({
width,
height,
xJitter: vanishXJitter,
yOffset: vanishYOffset,
});
const createStars = () =>
Array.from({ length: getFlightStarCount(width, height) }, () =>
createFlightStar({ width, height, isRespawn: false }),
);
setCanvasSize();
let stars: FlightStar[] = createStars();
const applyCenterProtection = (x: number, y: number, alpha: number) => {
const centerX = width / 2;
const centerY = height / 2;
const distance = Math.hypot(x - centerX, y - centerY);
if (distance >= centerProtectRadius) return alpha;
const ratio = clamp(distance / centerProtectRadius, 0, 1);
const attenuation = 0.35 + ratio * 0.65;
return alpha * attenuation;
};
const drawStar = ({
star,
fromX,
fromY,
toX,
toY,
}: {
star: FlightStar;
fromX: number;
fromY: number;
toX: number;
toY: number;
}) => {
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
const deltaX = toX - fromX;
const deltaY = toY - fromY;
const movementLength = Math.hypot(deltaX, deltaY);
if (star.tailLength < 1 || movementLength < 0.001) {
context.globalAlpha = visibleAlpha;
context.fillStyle = '#f8fbff';
context.beginPath();
context.arc(toX, toY, star.radius, 0, Math.PI * 2);
context.fill();
context.globalAlpha = 1;
return;
}
const directionX = deltaX / movementLength;
const directionY = deltaY / movementLength;
const tailX = toX - directionX * star.tailLength;
const tailY = toY - directionY * star.tailLength;
const gradient = context.createLinearGradient(tailX, tailY, toX, toY);
gradient.addColorStop(0, 'rgba(248, 251, 255, 0)');
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
context.strokeStyle = gradient;
context.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6);
context.beginPath();
context.moveTo(tailX, tailY);
context.lineTo(toX, toY);
context.stroke();
context.globalAlpha = Math.min(1, visibleAlpha + 0.08);
context.fillStyle = '#f8fbff';
context.beginPath();
context.arc(toX, toY, clamp(star.radius * 0.72, 0.6, 1.45), 0, Math.PI * 2);
context.fill();
context.globalAlpha = 1;
};
const drawCenterVeil = () => {
const vp = getVanishingPoint();
const veil = context.createRadialGradient(
vp.x,
vp.y,
centerProtectRadius * 0.18,
vp.x,
vp.y,
centerProtectRadius * 1.35,
);
veil.addColorStop(0, 'rgba(160, 185, 235, 0.08)');
veil.addColorStop(0.55, 'rgba(90, 114, 170, 0.03)');
veil.addColorStop(1, 'rgba(0, 0, 0, 0)');
context.fillStyle = veil;
context.fillRect(0, 0, width, height);
};
const drawVignette = () => {
const vignette = context.createRadialGradient(
width / 2,
height / 2,
Math.min(width, height) * 0.25,
width / 2,
height / 2,
Math.max(width, height) * 0.95,
);
vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
vignette.addColorStop(1, 'rgba(0, 0, 0, 0.82)');
context.fillStyle = vignette;
context.fillRect(0, 0, width, height);
};
const drawFrame = (moveStars: boolean) => {
context.fillStyle = 'rgba(2, 5, 10, 0.3)';
context.fillRect(0, 0, width, height);
drawCenterVeil();
stars.forEach((star, index) => {
const vp = getVanishingPoint();
const from = projectFlightStar(star, vp, star.z);
if (moveStars) {
star.z -= star.speed;
}
const to = projectFlightStar(star, vp, star.z);
if (
shouldRecycleFlightStar({
x: to.x,
y: to.y,
z: star.z,
width,
height,
})
) {
stars[index] = createFlightStar({ width, height, isRespawn: true });
return;
}
drawStar({
star,
fromX: from.x,
fromY: from.y,
toX: to.x,
toY: to.y,
});
});
drawVignette();
};
const render = () => {
drawFrame(true);
animationFrameId = requestAnimationFrame(render);
};
const renderStatic = () => {
context.clearRect(0, 0, width, height);
drawFrame(false);
};
const handleResize = () => {
setCanvasSize();
stars = createStars();
if (prefersReducedMotion) {
renderStatic();
}
};
const handleMotionChange = (event: MediaQueryListEvent) => {
prefersReducedMotion = event.matches;
if (prefersReducedMotion) {
cancelAnimationFrame(animationFrameId);
renderStatic();
return;
}
render();
};
window.addEventListener('resize', handleResize);
motionQuery.addEventListener('change', handleMotionChange);
if (prefersReducedMotion) {
renderStatic();
} else {
render();
}
return () => {
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', handleMotionChange);
cancelAnimationFrame(animationFrameId);
};
}, [vanishYOffset, centerProtectRadius]);
return <canvas ref={canvasRef} className="fixed inset-0 z-0 bg-black pointer-events-none" />;
}

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentVoyage } from '@/shared/lib/store';
export function useLobbyRedirect() {
const router = useRouter();
useEffect(() => {
const current = getCurrentVoyage();
if (current && current.status === 'in_progress') {
router.replace('/flight');
}
}, [router]);
}

View File

@@ -0,0 +1 @@
export { ConstellationScene } from './ui/ConstellationScene';

View File

@@ -0,0 +1,105 @@
export type LobbyStar = {
cx: number;
cy: number;
r: number;
armScale?: number;
};
export type LobbySegment = {
x1: number;
y1: number;
x2: number;
y2: number;
};
export type LobbyConstellation = {
key: 'orion' | 'auriga' | 'ursaMajor';
className: string;
viewBox: string;
colorClass: string;
stars: LobbyStar[];
segments: LobbySegment[];
};
export const LOBBY_CONSTELLATIONS: LobbyConstellation[] = [
{
key: 'orion',
className: 'absolute bottom-10 left-5 w-72 h-72 opacity-30',
viewBox: '0 0 100 100',
colorClass: 'text-indigo-200',
stars: [
{ cx: 25, cy: 15, r: 0.82 },
{ cx: 75, cy: 25, r: 0.96, armScale: 1.08 },
{ cx: 45, cy: 48, r: 0.58 },
{ cx: 50, cy: 50, r: 0.54 },
{ cx: 55, cy: 52, r: 0.6 },
{ cx: 30, cy: 85, r: 0.86 },
{ cx: 70, cy: 80, r: 0.98, armScale: 1.08 },
],
segments: [
{ x1: 25, y1: 15, x2: 45, y2: 48 },
{ x1: 75, y1: 25, x2: 55, y2: 52 },
{ x1: 45, y1: 48, x2: 30, y2: 85 },
{ x1: 55, y1: 52, x2: 70, y2: 80 },
],
},
{
key: 'auriga',
className: 'absolute top-10 right-10 w-64 h-64 opacity-25',
viewBox: '0 0 100 100',
colorClass: 'text-blue-200',
stars: [
{ cx: 50, cy: 15, r: 1.06, armScale: 1.1 },
{ cx: 20, cy: 35, r: 0.67 },
{ cx: 25, cy: 75, r: 0.65 },
{ cx: 75, cy: 75, r: 0.66 },
{ cx: 85, cy: 35, r: 0.76 },
],
segments: [
{ x1: 50, y1: 15, x2: 20, y2: 35 },
{ x1: 20, y1: 35, x2: 25, y2: 75 },
{ x1: 25, y1: 75, x2: 75, y2: 75 },
{ x1: 75, y1: 75, x2: 85, y2: 35 },
{ x1: 85, y1: 35, x2: 50, y2: 15 },
],
},
{
key: 'ursaMajor',
className: 'absolute top-20 left-10 w-80 h-48 opacity-25',
viewBox: '0 0 100 60',
colorClass: 'text-slate-200',
stars: [
{ cx: 10, cy: 20, r: 0.64 },
{ cx: 25, cy: 25, r: 0.67 },
{ cx: 40, cy: 35, r: 0.69 },
{ cx: 55, cy: 45, r: 0.99 },
{ cx: 75, cy: 45, r: 0.98 },
{ cx: 80, cy: 15, r: 0.93 },
{ cx: 60, cy: 10, r: 0.96 },
],
segments: [
{ x1: 10, y1: 20, x2: 25, y2: 25 },
{ x1: 25, y1: 25, x2: 40, y2: 35 },
{ x1: 40, y1: 35, x2: 55, y2: 45 },
{ x1: 55, y1: 45, x2: 75, y2: 45 },
{ x1: 75, y1: 45, x2: 80, y2: 15 },
{ x1: 80, y1: 15, x2: 60, y2: 10 },
{ x1: 60, y1: 10, x2: 55, y2: 45 },
],
},
];
export const LOBBY_STAR_TIMINGS = [
{ duration: 2.3, delay: 0.1 },
{ duration: 3.1, delay: 1.2 },
{ duration: 4.8, delay: 0.8 },
{ duration: 2.7, delay: 2.1 },
{ duration: 5.2, delay: 1.7 },
{ duration: 3.9, delay: 0.4 },
{ duration: 4.4, delay: 2.6 },
{ duration: 2.1, delay: 1.3 },
{ duration: 5.8, delay: 0.2 },
{ duration: 3.3, delay: 2.4 },
{ duration: 4.0, delay: 1.1 },
{ duration: 2.9, delay: 1.9 },
] as const;

View File

@@ -0,0 +1,49 @@
import { LOBBY_CONSTELLATIONS } from '@/features/lobby-starfield/model/constellationData';
import { StarGlint } from './StarGlint';
export function ConstellationScene() {
return (
<>
{LOBBY_CONSTELLATIONS.map((constellation, constellationIndex) => {
const starIndexOffset = LOBBY_CONSTELLATIONS.slice(0, constellationIndex).reduce(
(sum, item) => sum + item.stars.length,
0,
);
return (
<div key={constellation.key} className={constellation.className}>
<svg
viewBox={constellation.viewBox}
className={`h-full w-full fill-current ${constellation.colorClass}`}
>
{constellation.segments.map((segment, segmentIndex) => (
<line
key={`${constellation.key}-segment-${segmentIndex}`}
x1={segment.x1}
y1={segment.y1}
x2={segment.x2}
y2={segment.y2}
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
))}
{constellation.stars.map((star, starIndex) => {
const globalStarIndex = starIndexOffset + starIndex;
return (
<StarGlint
key={`${constellation.key}-star-${globalStarIndex}`}
starIndex={globalStarIndex}
star={star}
/>
);
})}
</svg>
</div>
);
})}
</>
);
}

View File

@@ -0,0 +1,123 @@
import { CSSProperties } from 'react';
import { LOBBY_STAR_TIMINGS, LobbyStar } from '@/features/lobby-starfield/model/constellationData';
export function StarGlint({
star,
starIndex,
}: {
star: LobbyStar;
starIndex: number;
}) {
const timing = LOBBY_STAR_TIMINGS[starIndex % LOBBY_STAR_TIMINGS.length];
const strengthTier =
star.r >= 0.95 ? 'bright' : star.r >= 0.72 ? 'mid' : 'faint';
const glintPeak =
strengthTier === 'bright' ? 0.7 : strengthTier === 'mid' ? 0.61 : 0.52;
const bloomPeak =
strengthTier === 'bright' ? 0.16 : strengthTier === 'mid' ? 0.12 : 0.08;
const coreLow =
strengthTier === 'bright' ? 0.9 : strengthTier === 'mid' ? 0.73 : 0.6;
const coreHigh =
strengthTier === 'bright' ? 1 : strengthTier === 'mid' ? 0.9 : 0.82;
const coreReduced =
strengthTier === 'bright' ? 0.9 : strengthTier === 'mid' ? 0.76 : 0.66;
const coreStyle = {
animationDuration: `${timing.duration}s`,
animationDelay: `${timing.delay}s`,
'--core-low': `${coreLow}`,
'--core-high': `${coreHigh}`,
'--core-reduced': `${coreReduced}`,
} as CSSProperties;
const glintStyle = {
animationDuration: `${timing.duration}s`,
animationDelay: `${timing.delay + 0.12}s`,
'--glint-peak': `${glintPeak}`,
'--glint-base': '0.02',
'--bloom-peak': `${bloomPeak}`,
'--bloom-base': '0.01',
} as CSSProperties;
const glintLength = star.r * 4.4 * (star.armScale ?? 1);
const gradientXId = `glint-x-${starIndex}`;
const gradientYId = `glint-y-${starIndex}`;
return (
<g>
<defs>
<linearGradient
id={gradientXId}
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient
id={gradientYId}
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<circle cx={star.cx} cy={star.cy} r={star.r} className="star-core" style={coreStyle} />
<circle
cx={star.cx}
cy={star.cy}
r={star.r * 1.9}
className="star-core-bloom"
style={coreStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint-bloom"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint-bloom"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
</g>
);
}

View File

@@ -1,25 +0,0 @@
import { Route } from "@/types";
export const ROUTES: Route[] = [
{
id: "station",
name: "우주정거장",
durationMinutes: 0, // 0 implies unlimited
tag: "대기/자유",
description: "시간 제한 없이 머무를 수 있는 안전지대",
},
{
id: "orion",
name: "오리온",
durationMinutes: 60,
tag: "딥워크",
description: "60분 집중 항해",
},
{
id: "gemini",
name: "쌍둥이자리",
durationMinutes: 30,
tag: "숏스프린트",
description: "30분 집중 항해",
},
];

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,25 @@
import { Route } from '@/shared/types';
export const ROUTES: Route[] = [
{
id: 'station',
name: '우주정거장',
durationMinutes: 0,
tag: '대기/자유',
description: '시간 제한 없이 머무를 수 있는 안전지대',
},
{
id: 'orion',
name: '오리온',
durationMinutes: 60,
tag: '딥워크',
description: '60분 집중 항해',
},
{
id: 'gemini',
name: '쌍둥이자리',
durationMinutes: 30,
tag: '숏스프린트',
description: '30분 집중 항해',
},
];

View File

@@ -0,0 +1,42 @@
export const FLIGHT_STARFIELD_TUNING = {
mobileBreakpoint: 768,
densityDivisor: 42000,
starCount: {
mobile: { min: 12, max: 30 },
desktop: { min: 18, max: 45 },
},
vanishXJitter: { min: 10, max: 25 },
speedTiers: {
slow: { chance: 0.9, min: 0.003, max: 0.007 },
medium: { chance: 0.99, min: 0.007, max: 0.011 },
fast: { min: 0.011, max: 0.014 },
},
tail: {
pointChance: 0.82,
shortChance: 0.86,
pointRange: { min: 0, max: 2.5 },
shortRange: { min: 2.5, max: 3.8 },
longRange: { min: 4, max: 10 },
},
spawnRadius: {
centerChance: 0.08,
ringChance: 0.8,
centerRange: { min: 0, max: 60 },
ringRange: { min: 80, max: 320 },
wideRange: { min: 120, maxScaleOfViewport: 0.7, maxAbsolute: 360 },
},
zRange: {
initial: { min: 0.55, max: 1.6 },
respawn: { min: 0.9, max: 1.55 },
recycleThreshold: 0.22,
},
radius: {
normal: { min: 0.7, max: 1.2 },
highlight: { min: 1.2, max: 1.8 },
highlightChance: 0.16,
},
alpha: {
normal: { min: 0.25, max: 0.55 },
highlight: { min: 0.55, max: 0.85 },
},
} as const;

6
src/shared/lib/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,5 @@
export const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
export const randomInRange = (min: number, max: number) =>
min + Math.random() * (max - min);

View File

@@ -0,0 +1,4 @@
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
export const getPrefersReducedMotionMediaQuery = () =>
window.matchMedia(REDUCED_MOTION_QUERY);

View File

@@ -1,4 +1,4 @@
import { Voyage, UserPreferences } from "@/types";
import { UserPreferences, Voyage } from '@/shared/types';
const KEYS = {
HISTORY: 'focustella_history_v1',
@@ -14,7 +14,6 @@ export const getHistory = (): Voyage[] => {
export const saveToHistory = (voyage: Voyage) => {
const history = getHistory();
// Add to beginning
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
};

View File

@@ -6,7 +6,12 @@ export interface Route {
description: string;
}
export type VoyageStatus = 'completed' | 'partial' | 'reoriented' | 'aborted' | 'in_progress';
export type VoyageStatus =
| 'completed'
| 'partial'
| 'reoriented'
| 'aborted'
| 'in_progress';
export interface Voyage {
id: string;

View File

@@ -0,0 +1 @@
export { FlightBackgroundWidget } from './ui/FlightBackgroundWidget';

View File

@@ -0,0 +1,5 @@
import { FlightStarfieldCanvas } from '@/features/flight-starfield';
export function FlightBackgroundWidget() {
return <FlightStarfieldCanvas vanishYOffset={-68} centerProtectRadius={200} />;
}

View File

@@ -0,0 +1 @@
export { FlightHudWidget } from './ui/FlightHudWidget';

View File

@@ -0,0 +1,49 @@
import { useFlightSession } from '@/features/flight-session/model/useFlightSession';
export function FlightHudWidget() {
const {
voyage,
isPaused,
formattedTime,
timeLeft,
handlePauseToggle,
handleFinish,
} = useFlightSession();
if (!voyage) return null;
return (
<>
<div className="absolute top-8 z-10 text-center">
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
{voyage.routeName} · {isPaused ? '일시정지' : '순항 중'}
</span>
</div>
<div
className={`relative z-10 my-12 font-mono text-7xl font-light tracking-tighter tabular-nums drop-shadow-2xl transition-opacity duration-300 md:text-9xl ${isPaused ? 'opacity-50' : 'opacity-100'}`}
>
{formattedTime}
</div>
<div className="relative z-10 mb-24 max-w-2xl px-4 text-center text-xl font-medium leading-relaxed text-slate-200 drop-shadow-md md:text-2xl">
&ldquo;{voyage.missionText}&rdquo;
</div>
<div className="absolute bottom-12 z-10 flex gap-6">
<button
onClick={handlePauseToggle}
className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white"
>
{isPaused ? '다시 시작' : '일시정지'}
</button>
<button
onClick={handleFinish}
className="rounded-full bg-slate-100 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-900 shadow-lg shadow-white/10 transition-all hover:bg-indigo-500 hover:text-white"
>
{timeLeft === 0 ? '도착 (회고)' : '항해 종료'}
</button>
</div>
</>
);
}

View File

@@ -0,0 +1 @@
export { LobbyBackgroundWidget } from './ui/LobbyBackgroundWidget';

View File

@@ -0,0 +1,9 @@
import { ConstellationScene } from '@/features/lobby-starfield';
export function LobbyBackgroundWidget() {
return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
<ConstellationScene />
</div>
);
}

View File

@@ -0,0 +1 @@
export { LobbyRoutesPanel } from './ui/LobbyRoutesPanel';

View File

@@ -0,0 +1,151 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
import { useLobbyRedirect } from '@/features/lobby-session/model/useLobbyRedirect';
import { ROUTES } from '@/shared/config/routes';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
function RouteCard({
route,
isCTA = false,
onLaunch,
}: {
route: (typeof ROUTES)[number];
isCTA?: boolean;
onLaunch: (route: (typeof ROUTES)[number]) => void;
}) {
return (
<div
className={`group relative flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-lg backdrop-blur-md transition-all duration-300 hover:border-indigo-500/50 hover:bg-slate-800/80 hover:shadow-indigo-900/20 ${isCTA ? 'min-h-[200px] items-center justify-center text-center' : ''}`}
>
<div
className={`relative z-10 flex w-full ${isCTA ? 'flex-col items-center gap-4' : 'mb-4 items-start justify-between'}`}
>
<div className={isCTA ? 'flex flex-col items-center' : ''}>
<h3
className={`font-bold text-indigo-100 transition-colors group-hover:text-white ${isCTA ? 'text-3xl' : 'text-xl'}`}
>
{route.name}
</h3>
<span className="mt-2 inline-block rounded-full border border-slate-800 bg-slate-950/50 px-2 py-0.5 text-xs font-medium text-slate-400">
{route.tag}
</span>
</div>
<span
className={`font-light text-slate-300 transition-colors group-hover:text-white ${isCTA ? 'mt-2 text-4xl' : 'text-3xl'}`}
>
{route.durationMinutes === 0 ? '∞' : route.durationMinutes}
<span className="ml-1 text-sm text-slate-500">
{route.durationMinutes === 0 ? '' : 'min'}
</span>
</span>
</div>
{!isCTA && (
<p className="relative z-10 mb-8 w-full flex-1 text-left text-sm leading-relaxed text-slate-400">
{route.description}
</p>
)}
{isCTA && (
<p className="relative z-10 mb-6 max-w-lg text-base text-slate-400">
{route.description}
</p>
)}
<button
type="button"
onClick={() => onLaunch(route)}
className={`relative z-10 flex items-center justify-center rounded-xl bg-indigo-600 font-bold text-white shadow-lg shadow-indigo-900/30 transition-all hover:bg-indigo-500 active:scale-[0.98] ${isCTA ? 'w-full max-w-md py-4 text-lg' : 'w-full py-4'}`}
>
{isCTA ? '정거장 진입 (대기)' : '바로 출항'}
</button>
</div>
);
}
export function LobbyRoutesPanel() {
useLobbyRedirect();
const router = useRouter();
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [isBoardingOpen, setIsBoardingOpen] = useState(false);
const stationRoute = ROUTES[0];
const normalRoutes = ROUTES.slice(1);
const selectedRoute =
ROUTES.find((route) => route.id === selectedRouteId) ?? stationRoute;
const handleOpenBoarding = (route: (typeof ROUTES)[number]) => {
setSelectedRouteId(route.id);
setIsBoardingOpen(true);
};
const handleDocking = (mission: string) => {
const started = startVoyage({
route: selectedRoute,
mission,
});
if (!started) return;
setIsBoardingOpen(false);
router.push('/flight');
};
return (
<div className="relative z-10 flex min-h-[calc(100vh-80px)] flex-col items-center justify-center p-6 md:p-12">
<div className="mx-auto mb-12 max-w-2xl space-y-4 rounded-3xl border border-slate-800/30 bg-slate-950/30 p-6 text-center backdrop-blur-sm">
<h1 className="text-3xl font-bold tracking-tight text-slate-100 md:text-5xl">
?
</h1>
<p className="text-lg text-slate-300"> .</p>
</div>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<div className="w-full">
<RouteCard route={stationRoute} isCTA={true} onLaunch={handleOpenBoarding} />
</div>
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
{normalRoutes.map((route) => (
<RouteCard key={route.id} route={route} onLaunch={handleOpenBoarding} />
))}
</div>
</div>
<Dialog open={isBoardingOpen} onOpenChange={setIsBoardingOpen}>
<DialogContent
className="max-w-xl border-slate-800 bg-slate-950 text-slate-100"
showCloseButton={true}
>
<DialogHeader className="mb-2">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-widest text-indigo-400">
Boarding Check
</h2>
<DialogTitle className="text-2xl font-bold text-white">
{selectedRoute.name}
</DialogTitle>
<DialogDescription className="text-slate-400">
.
</DialogDescription>
</DialogHeader>
<BoardingMissionForm
onDock={handleDocking}
onCancel={() => setIsBoardingOpen(false)}
autoFocus={true}
compact={true}
/>
</DialogContent>
</Dialog>
</div>
);
}