refactor: fsd 구조로 변환
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { ROUTES } from '@/lib/constants';
|
import { ROUTES } from '@/shared/config/routes';
|
||||||
import { saveCurrentVoyage } from '@/lib/store';
|
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
|
||||||
import { Voyage } from '@/types';
|
|
||||||
|
|
||||||
function BoardingContent() {
|
function BoardingContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -12,24 +11,9 @@ function BoardingContent() {
|
|||||||
const routeId = searchParams.get('routeId');
|
const routeId = searchParams.get('routeId');
|
||||||
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
|
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
|
||||||
|
|
||||||
const [mission, setMission] = useState('');
|
const handleDocking = (mission: string) => {
|
||||||
const [notes, setNotes] = useState('');
|
const started = startVoyage({ route, mission });
|
||||||
|
if (!started) return;
|
||||||
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);
|
|
||||||
router.push('/flight');
|
router.push('/flight');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,41 +25,11 @@ function BoardingContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 flex-1">
|
<div className="space-y-8 flex-1">
|
||||||
<div className="space-y-3">
|
<BoardingMissionForm
|
||||||
<label className="block text-sm font-medium text-slate-300">
|
onDock={handleDocking}
|
||||||
이번 항해의 핵심 목표
|
autoFocus={true}
|
||||||
</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
|
|
||||||
/>
|
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/lib/store';
|
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store';
|
||||||
import { Voyage, VoyageStatus } from '@/types';
|
import { Voyage, VoyageStatus } from '@/shared/types';
|
||||||
|
|
||||||
export default function DebriefPage() {
|
export default function DebriefPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,118 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { FlightBackgroundWidget } from '@/widgets/flight-background';
|
||||||
import { useRouter } from 'next/navigation';
|
import { FlightHudWidget } from '@/widgets/flight-hud';
|
||||||
import { getCurrentVoyage, saveCurrentVoyage, getPreferences } from '@/lib/store';
|
|
||||||
import { Voyage } from '@/types';
|
|
||||||
import FlightBackground from '@/components/FlightBackground';
|
|
||||||
|
|
||||||
export default function FlightPage() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center p-6 text-white relative overflow-hidden min-h-[calc(100vh-64px)]">
|
<div className="relative flex min-h-[calc(100vh-64px)] flex-1 flex-col items-center justify-center overflow-hidden p-6 text-white">
|
||||||
<FlightBackground vanishYOffset={-68} centerProtectRadius={200} />
|
<FlightBackgroundWidget />
|
||||||
|
<FlightHudWidget />
|
||||||
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useEffect, useState, use } from 'react';
|
import { useEffect, useState, use } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getHistory } from '@/lib/store';
|
import { getHistory } from '@/shared/lib/store';
|
||||||
import { Voyage } from '@/types';
|
import { Voyage } from '@/shared/types';
|
||||||
|
|
||||||
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
// Next.js 15: params is a Promise
|
// Next.js 15: params is a Promise
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getHistory } from '@/lib/store';
|
import { getHistory } from '@/shared/lib/store';
|
||||||
import { Voyage, VoyageStatus } from '@/types';
|
import { Voyage, VoyageStatus } from '@/shared/types';
|
||||||
|
|
||||||
export default function LogListPage() {
|
export default function LogListPage() {
|
||||||
const [logs, setLogs] = useState<Voyage[]>([]);
|
const [logs, setLogs] = useState<Voyage[]>([]);
|
||||||
|
|||||||
371
src/app/page.tsx
371
src/app/page.tsx
@@ -1,372 +1,13 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { ROUTES } from "@/lib/constants";
|
import { LobbyBackgroundWidget } from '@/widgets/lobby-background';
|
||||||
import { getCurrentVoyage } from "@/lib/store";
|
import { LobbyRoutesPanel } from '@/widgets/lobby-routes';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 relative animate-in fade-in duration-500">
|
<div className="relative flex flex-1 flex-col animate-in fade-in duration-500">
|
||||||
<LobbyBackground />
|
<LobbyBackgroundWidget />
|
||||||
|
<LobbyRoutesPanel />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getPreferences, savePreferences } from '@/lib/store';
|
import { getPreferences, savePreferences } from '@/shared/lib/store';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [hideSeconds, setHideSeconds] = useState(false);
|
const [hideSeconds, setHideSeconds] = useState(false);
|
||||||
|
|||||||
@@ -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" />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { Slot } from "radix-ui";
|
import { Slot } from "radix-ui";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/shared/lib/cn"
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { XIcon } from "lucide-react";
|
|||||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "./button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/shared/lib/cn"
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
|
|||||||
2
src/features/boarding/index.ts
Normal file
2
src/features/boarding/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { BoardingMissionForm } from './ui/BoardingMissionForm';
|
||||||
|
export { startVoyage } from './model/startVoyage';
|
||||||
33
src/features/boarding/model/startVoyage.ts
Normal file
33
src/features/boarding/model/startVoyage.ts
Normal 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;
|
||||||
|
};
|
||||||
54
src/features/boarding/ui/BoardingMissionForm.tsx
Normal file
54
src/features/boarding/ui/BoardingMissionForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/features/flight-session/model/useFlightSession.ts
Normal file
100
src/features/flight-session/model/useFlightSession.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/flight-starfield/index.ts
Normal file
1
src/features/flight-starfield/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FlightStarfieldCanvas } from './ui/FlightStarfieldCanvas';
|
||||||
21
src/features/flight-starfield/lib/projection.ts
Normal file
21
src/features/flight-starfield/lib/projection.ts
Normal 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,
|
||||||
|
});
|
||||||
175
src/features/flight-starfield/model/starfieldModel.ts
Normal file
175
src/features/flight-starfield/model/starfieldModel.ts
Normal 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;
|
||||||
14
src/features/flight-starfield/model/types.ts
Normal file
14
src/features/flight-starfield/model/types.ts
Normal 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;
|
||||||
|
};
|
||||||
249
src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
Normal file
249
src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
15
src/features/lobby-session/model/useLobbyRedirect.ts
Normal file
15
src/features/lobby-session/model/useLobbyRedirect.ts
Normal 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]);
|
||||||
|
}
|
||||||
1
src/features/lobby-starfield/index.ts
Normal file
1
src/features/lobby-starfield/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ConstellationScene } from './ui/ConstellationScene';
|
||||||
105
src/features/lobby-starfield/model/constellationData.ts
Normal file
105
src/features/lobby-starfield/model/constellationData.ts
Normal 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;
|
||||||
49
src/features/lobby-starfield/ui/ConstellationScene.tsx
Normal file
49
src/features/lobby-starfield/ui/ConstellationScene.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/features/lobby-starfield/ui/StarGlint.tsx
Normal file
123
src/features/lobby-starfield/ui/StarGlint.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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분 집중 항해",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
25
src/shared/config/routes.ts
Normal file
25
src/shared/config/routes.ts
Normal 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분 집중 항해',
|
||||||
|
},
|
||||||
|
];
|
||||||
42
src/shared/config/starfield.ts
Normal file
42
src/shared/config/starfield.ts
Normal 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
6
src/shared/lib/cn.ts
Normal 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));
|
||||||
|
}
|
||||||
5
src/shared/lib/math/number.ts
Normal file
5
src/shared/lib/math/number.ts
Normal 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);
|
||||||
4
src/shared/lib/motion/prefersReducedMotion.ts
Normal file
4
src/shared/lib/motion/prefersReducedMotion.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
export const getPrefersReducedMotionMediaQuery = () =>
|
||||||
|
window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Voyage, UserPreferences } from "@/types";
|
import { UserPreferences, Voyage } from '@/shared/types';
|
||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
HISTORY: 'focustella_history_v1',
|
HISTORY: 'focustella_history_v1',
|
||||||
@@ -14,7 +14,6 @@ export const getHistory = (): Voyage[] => {
|
|||||||
|
|
||||||
export const saveToHistory = (voyage: Voyage) => {
|
export const saveToHistory = (voyage: Voyage) => {
|
||||||
const history = getHistory();
|
const history = getHistory();
|
||||||
// Add to beginning
|
|
||||||
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
|
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6,7 +6,12 @@ export interface Route {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VoyageStatus = 'completed' | 'partial' | 'reoriented' | 'aborted' | 'in_progress';
|
export type VoyageStatus =
|
||||||
|
| 'completed'
|
||||||
|
| 'partial'
|
||||||
|
| 'reoriented'
|
||||||
|
| 'aborted'
|
||||||
|
| 'in_progress';
|
||||||
|
|
||||||
export interface Voyage {
|
export interface Voyage {
|
||||||
id: string;
|
id: string;
|
||||||
1
src/widgets/flight-background/index.ts
Normal file
1
src/widgets/flight-background/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FlightBackgroundWidget } from './ui/FlightBackgroundWidget';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { FlightStarfieldCanvas } from '@/features/flight-starfield';
|
||||||
|
|
||||||
|
export function FlightBackgroundWidget() {
|
||||||
|
return <FlightStarfieldCanvas vanishYOffset={-68} centerProtectRadius={200} />;
|
||||||
|
}
|
||||||
1
src/widgets/flight-hud/index.ts
Normal file
1
src/widgets/flight-hud/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FlightHudWidget } from './ui/FlightHudWidget';
|
||||||
49
src/widgets/flight-hud/ui/FlightHudWidget.tsx
Normal file
49
src/widgets/flight-hud/ui/FlightHudWidget.tsx
Normal 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">
|
||||||
|
“{voyage.missionText}”
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/widgets/lobby-background/index.ts
Normal file
1
src/widgets/lobby-background/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LobbyBackgroundWidget } from './ui/LobbyBackgroundWidget';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/widgets/lobby-routes/index.ts
Normal file
1
src/widgets/lobby-routes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LobbyRoutesPanel } from './ui/LobbyRoutesPanel';
|
||||||
151
src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx
Normal file
151
src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user