From d60d4ccd9e2aa710495afc18ee2e8394743783a5 Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 13 Feb 2026 15:20:35 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20fsd=20=EA=B5=AC=EC=A1=B0=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/boarding/page.tsx | 66 +--- src/app/debrief/page.tsx | 4 +- src/app/flight/page.tsx | 115 +----- src/app/log/[id]/page.tsx | 4 +- src/app/log/page.tsx | 4 +- src/app/page.tsx | 371 +----------------- src/app/settings/page.tsx | 2 +- src/components/FlightBackground.tsx | 312 --------------- src/components/LobbyBackground.tsx | 332 ---------------- src/components/ui/button.tsx | 2 +- src/components/ui/card.tsx | 2 +- src/components/ui/dialog.tsx | 4 +- src/components/ui/input.tsx | 2 +- src/components/ui/separator.tsx | 2 +- src/features/boarding/index.ts | 2 + src/features/boarding/model/startVoyage.ts | 33 ++ .../boarding/ui/BoardingMissionForm.tsx | 54 +++ .../flight-session/model/useFlightSession.ts | 100 +++++ src/features/flight-starfield/index.ts | 1 + .../flight-starfield/lib/projection.ts | 21 + .../flight-starfield/model/starfieldModel.ts | 175 +++++++++ src/features/flight-starfield/model/types.ts | 14 + .../ui/FlightStarfieldCanvas.tsx | 249 ++++++++++++ .../lobby-session/model/useLobbyRedirect.ts | 15 + src/features/lobby-starfield/index.ts | 1 + .../model/constellationData.ts | 105 +++++ .../lobby-starfield/ui/ConstellationScene.tsx | 49 +++ src/features/lobby-starfield/ui/StarGlint.tsx | 123 ++++++ src/lib/constants.ts | 25 -- src/lib/utils.ts | 6 - src/shared/config/routes.ts | 25 ++ src/shared/config/starfield.ts | 42 ++ src/shared/lib/cn.ts | 6 + src/shared/lib/math/number.ts | 5 + src/shared/lib/motion/prefersReducedMotion.ts | 4 + src/{ => shared}/lib/store.ts | 3 +- src/{ => shared}/types/index.ts | 7 +- src/widgets/flight-background/index.ts | 1 + .../ui/FlightBackgroundWidget.tsx | 5 + src/widgets/flight-hud/index.ts | 1 + src/widgets/flight-hud/ui/FlightHudWidget.tsx | 49 +++ src/widgets/lobby-background/index.ts | 1 + .../ui/LobbyBackgroundWidget.tsx | 9 + src/widgets/lobby-routes/index.ts | 1 + .../lobby-routes/ui/LobbyRoutesPanel.tsx | 151 +++++++ 45 files changed, 1283 insertions(+), 1222 deletions(-) delete mode 100644 src/components/FlightBackground.tsx delete mode 100644 src/components/LobbyBackground.tsx create mode 100644 src/features/boarding/index.ts create mode 100644 src/features/boarding/model/startVoyage.ts create mode 100644 src/features/boarding/ui/BoardingMissionForm.tsx create mode 100644 src/features/flight-session/model/useFlightSession.ts create mode 100644 src/features/flight-starfield/index.ts create mode 100644 src/features/flight-starfield/lib/projection.ts create mode 100644 src/features/flight-starfield/model/starfieldModel.ts create mode 100644 src/features/flight-starfield/model/types.ts create mode 100644 src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx create mode 100644 src/features/lobby-session/model/useLobbyRedirect.ts create mode 100644 src/features/lobby-starfield/index.ts create mode 100644 src/features/lobby-starfield/model/constellationData.ts create mode 100644 src/features/lobby-starfield/ui/ConstellationScene.tsx create mode 100644 src/features/lobby-starfield/ui/StarGlint.tsx delete mode 100644 src/lib/constants.ts delete mode 100644 src/lib/utils.ts create mode 100644 src/shared/config/routes.ts create mode 100644 src/shared/config/starfield.ts create mode 100644 src/shared/lib/cn.ts create mode 100644 src/shared/lib/math/number.ts create mode 100644 src/shared/lib/motion/prefersReducedMotion.ts rename src/{ => shared}/lib/store.ts (94%) rename src/{ => shared}/types/index.ts (81%) create mode 100644 src/widgets/flight-background/index.ts create mode 100644 src/widgets/flight-background/ui/FlightBackgroundWidget.tsx create mode 100644 src/widgets/flight-hud/index.ts create mode 100644 src/widgets/flight-hud/ui/FlightHudWidget.tsx create mode 100644 src/widgets/lobby-background/index.ts create mode 100644 src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx create mode 100644 src/widgets/lobby-routes/index.ts create mode 100644 src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx diff --git a/src/app/boarding/page.tsx b/src/app/boarding/page.tsx index 323b764..1cd2305 100644 --- a/src/app/boarding/page.tsx +++ b/src/app/boarding/page.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useState, Suspense } from 'react'; +import { Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { ROUTES } from '@/lib/constants'; -import { saveCurrentVoyage } from '@/lib/store'; -import { Voyage } from '@/types'; +import { ROUTES } from '@/shared/config/routes'; +import { BoardingMissionForm, startVoyage } from '@/features/boarding'; function BoardingContent() { const router = useRouter(); @@ -12,24 +11,9 @@ function BoardingContent() { const routeId = searchParams.get('routeId'); const route = ROUTES.find(r => r.id === routeId) || ROUTES[0]; - const [mission, setMission] = useState(''); - const [notes, setNotes] = useState(''); - - const handleDocking = () => { - if (!mission.trim()) return; - - const newVoyage: Voyage = { - id: crypto.randomUUID(), - routeId: route.id, - routeName: route.name, - durationMinutes: route.durationMinutes, - startedAt: Date.now(), - status: 'in_progress', - missionText: mission, - notes: notes, - }; - - saveCurrentVoyage(newVoyage); + const handleDocking = (mission: string) => { + const started = startVoyage({ route, mission }); + if (!started) return; router.push('/flight'); }; @@ -41,41 +25,11 @@ function BoardingContent() {
-
- - 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 - /> -
- -
- - 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" - /> -
+
- - ); } diff --git a/src/app/debrief/page.tsx b/src/app/debrief/page.tsx index 91c7823..642d759 100644 --- a/src/app/debrief/page.tsx +++ b/src/app/debrief/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/lib/store'; -import { Voyage, VoyageStatus } from '@/types'; +import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store'; +import { Voyage, VoyageStatus } from '@/shared/types'; export default function DebriefPage() { const router = useRouter(); diff --git a/src/app/flight/page.tsx b/src/app/flight/page.tsx index 75ef5ca..1e9d082 100644 --- a/src/app/flight/page.tsx +++ b/src/app/flight/page.tsx @@ -1,118 +1,13 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; -import { useRouter } from 'next/navigation'; -import { getCurrentVoyage, saveCurrentVoyage, getPreferences } from '@/lib/store'; -import { Voyage } from '@/types'; -import FlightBackground from '@/components/FlightBackground'; +import { FlightBackgroundWidget } from '@/widgets/flight-background'; +import { FlightHudWidget } from '@/widgets/flight-hud'; export default function FlightPage() { - const router = useRouter(); - const [voyage, setVoyage] = useState(null); - const [timeLeft, setTimeLeft] = useState(0); // seconds - const [isPaused, setIsPaused] = useState(false); - const [hideSeconds, setHideSeconds] = useState(false); - - const endTimeRef = useRef(0); - - useEffect(() => { - const current = getCurrentVoyage(); - if (!current || current.status !== 'in_progress') { - router.replace('/'); - return; - } - setVoyage(current); - - const now = Date.now(); - const target = current.startedAt + (current.durationMinutes * 60 * 1000); - endTimeRef.current = target; - - const remainingMs = target - now; - setTimeLeft(Math.max(0, Math.ceil(remainingMs / 1000))); - - const prefs = getPreferences(); - setHideSeconds(prefs.hideSeconds); - }, [router]); - - useEffect(() => { - if (!voyage || isPaused) return; - - const interval = setInterval(() => { - const now = Date.now(); - const diff = endTimeRef.current - now; - - if (diff <= 0) { - setTimeLeft(0); - clearInterval(interval); - } else { - setTimeLeft(Math.ceil(diff / 1000)); - } - }, 1000); - - return () => clearInterval(interval); - }, [voyage, isPaused]); - - const handlePauseToggle = () => { - if (isPaused) { - endTimeRef.current = Date.now() + (timeLeft * 1000); - setIsPaused(false); - } else { - setIsPaused(true); - } - }; - - const handleFinish = () => { - if (!voyage) return; - const ended: Voyage = { ...voyage, endedAt: Date.now() }; - saveCurrentVoyage(ended); - router.push('/debrief'); - }; - - if (!voyage) return null; - - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - if (hideSeconds) return `${m}m`; - return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; - }; - return ( -
- - - {/* UI Element 1: Label */} -
- - {voyage.routeName} · {isPaused ? '일시정지' : '순항 중'} - -
- - {/* UI Element 2: Timer */} -
- {formatTime(timeLeft)} -
- - {/* UI Element 3: Mission */} -
- “{voyage.missionText}” -
- - {/* UI Element 4 & 5: Controls */} -
- - -
+
+ +
); } diff --git a/src/app/log/[id]/page.tsx b/src/app/log/[id]/page.tsx index 949a6eb..888cb58 100644 --- a/src/app/log/[id]/page.tsx +++ b/src/app/log/[id]/page.tsx @@ -3,8 +3,8 @@ import { useEffect, useState, use } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { getHistory } from '@/lib/store'; -import { Voyage } from '@/types'; +import { getHistory } from '@/shared/lib/store'; +import { Voyage } from '@/shared/types'; export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) { // Next.js 15: params is a Promise diff --git a/src/app/log/page.tsx b/src/app/log/page.tsx index 538adf6..1460232 100644 --- a/src/app/log/page.tsx +++ b/src/app/log/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { getHistory } from '@/lib/store'; -import { Voyage, VoyageStatus } from '@/types'; +import { getHistory } from '@/shared/lib/store'; +import { Voyage, VoyageStatus } from '@/shared/types'; export default function LogListPage() { const [logs, setLogs] = useState([]); diff --git a/src/app/page.tsx b/src/app/page.tsx index 9e90222..ae33e61 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,372 +1,13 @@ -"use client"; +'use client'; -import { ROUTES } from "@/lib/constants"; -import { getCurrentVoyage } from "@/lib/store"; -import { Route } from "@/types"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { CSSProperties, useEffect } from "react"; - -type Star = { - cx: number; - cy: number; - r: number; - armScale?: number; -}; - -type Segment = { - x1: number; - y1: number; - x2: number; - y2: number; -}; - -type Constellation = { - key: "orion" | "auriga" | "ursaMajor"; - className: string; - viewBox: string; - colorClass: string; - stars: Star[]; - segments: Segment[]; -}; - -const CONSTELLATIONS: Constellation[] = [ - { - key: "orion", - className: "absolute bottom-10 left-5 w-72 h-72 opacity-30", - viewBox: "0 0 100 100", - colorClass: "text-indigo-200", - stars: [ - { cx: 25, cy: 15, r: 0.82 }, - { cx: 75, cy: 25, r: 0.96, armScale: 1.08 }, - { cx: 45, cy: 48, r: 0.58 }, - { cx: 50, cy: 50, r: 0.54 }, - { cx: 55, cy: 52, r: 0.6 }, - { cx: 30, cy: 85, r: 0.86 }, - { cx: 70, cy: 80, r: 0.98, armScale: 1.08 }, - ], - segments: [ - { x1: 25, y1: 15, x2: 45, y2: 48 }, - { x1: 75, y1: 25, x2: 55, y2: 52 }, - { x1: 45, y1: 48, x2: 30, y2: 85 }, - { x1: 55, y1: 52, x2: 70, y2: 80 }, - ], - }, - { - key: "auriga", - className: "absolute top-10 right-10 w-64 h-64 opacity-25", - viewBox: "0 0 100 100", - colorClass: "text-blue-200", - stars: [ - { cx: 50, cy: 15, r: 1.06, armScale: 1.1 }, - { cx: 20, cy: 35, r: 0.67 }, - { cx: 25, cy: 75, r: 0.65 }, - { cx: 75, cy: 75, r: 0.66 }, - { cx: 85, cy: 35, r: 0.76 }, - ], - segments: [ - { x1: 50, y1: 15, x2: 20, y2: 35 }, - { x1: 20, y1: 35, x2: 25, y2: 75 }, - { x1: 25, y1: 75, x2: 75, y2: 75 }, - { x1: 75, y1: 75, x2: 85, y2: 35 }, - { x1: 85, y1: 35, x2: 50, y2: 15 }, - ], - }, - { - key: "ursaMajor", - className: "absolute top-20 left-10 w-80 h-48 opacity-25", - viewBox: "0 0 100 60", - colorClass: "text-slate-200", - stars: [ - { cx: 10, cy: 20, r: 0.64 }, - { cx: 25, cy: 25, r: 0.67 }, - { cx: 40, cy: 35, r: 0.69 }, - { cx: 55, cy: 45, r: 0.99 }, - { cx: 75, cy: 45, r: 0.98 }, - { cx: 80, cy: 15, r: 0.93 }, - { cx: 60, cy: 10, r: 0.96 }, - ], - segments: [ - { x1: 10, y1: 20, x2: 25, y2: 25 }, - { x1: 25, y1: 25, x2: 40, y2: 35 }, - { x1: 40, y1: 35, x2: 55, y2: 45 }, - { x1: 55, y1: 45, x2: 75, y2: 45 }, - { x1: 75, y1: 45, x2: 80, y2: 15 }, - { x1: 80, y1: 15, x2: 60, y2: 10 }, - { x1: 60, y1: 10, x2: 55, y2: 45 }, - ], - }, -]; - -const STAR_STYLES = [ - { duration: 2.3, delay: 0.1 }, - { duration: 3.1, delay: 1.2 }, - { duration: 4.8, delay: 0.8 }, - { duration: 2.7, delay: 2.1 }, - { duration: 5.2, delay: 1.7 }, - { duration: 3.9, delay: 0.4 }, - { duration: 4.4, delay: 2.6 }, - { duration: 2.1, delay: 1.3 }, - { duration: 5.8, delay: 0.2 }, - { duration: 3.3, delay: 2.4 }, - { duration: 4.0, delay: 1.1 }, - { duration: 2.9, delay: 1.9 }, -] as const; - -function StarGlint({ starIndex, star }: { starIndex: number; star: Star }) { - const timing = STAR_STYLES[starIndex % STAR_STYLES.length]; - const strengthTier = - star.r >= 0.95 ? "bright" : star.r >= 0.72 ? "mid" : "faint"; - const glintPeak = - strengthTier === "bright" ? 0.7 : strengthTier === "mid" ? 0.61 : 0.52; - const bloomPeak = - strengthTier === "bright" ? 0.16 : strengthTier === "mid" ? 0.12 : 0.08; - const coreLow = - strengthTier === "bright" ? 0.9 : strengthTier === "mid" ? 0.73 : 0.6; - const coreHigh = - strengthTier === "bright" ? 1 : strengthTier === "mid" ? 0.9 : 0.82; - const coreReduced = - strengthTier === "bright" ? 0.9 : strengthTier === "mid" ? 0.76 : 0.66; - const coreStyle = { - animationDuration: `${timing.duration}s`, - animationDelay: `${timing.delay}s`, - "--core-low": `${coreLow}`, - "--core-high": `${coreHigh}`, - "--core-reduced": `${coreReduced}`, - } as CSSProperties; - const glintStyle = { - animationDuration: `${timing.duration}s`, - animationDelay: `${timing.delay + 0.12}s`, - "--glint-peak": `${glintPeak}`, - "--glint-base": "0.02", - "--bloom-peak": `${bloomPeak}`, - "--bloom-base": "0.01", - } as CSSProperties; - const glintLength = star.r * 4.4 * (star.armScale ?? 1); - const gradientXId = `glint-x-${starIndex}`; - const gradientYId = `glint-y-${starIndex}`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - ); -} - -function LobbyBackground() { - return ( -
- {CONSTELLATIONS.map((constellation, constellationIndex) => ( -
- - {constellation.segments.map((segment, segmentIndex) => ( - - ))} - - {constellation.stars.map((star, starIndex) => { - const globalStarIndex = - CONSTELLATIONS.slice(0, constellationIndex).reduce( - (sum, item) => sum + item.stars.length, - 0, - ) + starIndex; - - return ( - - ); - })} - -
- ))} -
- ); -} - -function RouteCard({ - route, - isCTA = false, -}: { - route: Route; - isCTA?: boolean; -}) { - return ( -
-
-
-

- {route.name} -

- - {route.tag} - -
- - {route.durationMinutes === 0 ? "∞" : route.durationMinutes} - - {route.durationMinutes === 0 ? "" : "min"} - - -
- - {!isCTA && ( -

- {route.description} -

- )} - - {isCTA && ( -

- {route.description} -

- )} - - - {isCTA ? "정거장 진입 (대기)" : "바로 출항"} - -
- ); -} +import { LobbyBackgroundWidget } from '@/widgets/lobby-background'; +import { LobbyRoutesPanel } from '@/widgets/lobby-routes'; export default function Home() { - const router = useRouter(); - - useEffect(() => { - const current = getCurrentVoyage(); - if (current && current.status === "in_progress") { - router.replace("/flight"); - } - }, [router]); - - const stationRoute = ROUTES[0]; - const normalRoutes = ROUTES.slice(1); - return ( -
- - -
-
-

- 어느 별자리로 출항할까요? -

-

몰입하기 좋은 궤도입니다.

-
- -
-
- -
- -
- {normalRoutes.map((route) => ( - - ))} -
-
-
+
+ +
); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 23025d5..e08970e 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { getPreferences, savePreferences } from '@/lib/store'; +import { getPreferences, savePreferences } from '@/shared/lib/store'; export default function SettingsPage() { const [hideSeconds, setHideSeconds] = useState(false); diff --git a/src/components/FlightBackground.tsx b/src/components/FlightBackground.tsx deleted file mode 100644 index 28f88c5..0000000 --- a/src/components/FlightBackground.tsx +++ /dev/null @@ -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(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 ; -} diff --git a/src/components/LobbyBackground.tsx b/src/components/LobbyBackground.tsx deleted file mode 100644 index e5e3df8..0000000 --- a/src/components/LobbyBackground.tsx +++ /dev/null @@ -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
; - - return ( -
- {/* Orion - Bottom Left */} -
- - - - - - - - - - - - - - -
- - {/* Auriga (마차부자리) - Top Right */} -
- - {" "} - {/* Capella */} - - - - - - - - - - -
- - {/* Big Dipper (북두칠성) - Top Left */} -
- - {/* Handle */} - - - - {/* Bowl */} - - - - - - - - - - - - - -
-
- ); -} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index da88b0f..f60bd2a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/shared/lib/cn"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 681ad98..c34ed1f 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,6 +1,6 @@ import * as React from "react" -import { cn } from "@/lib/utils" +import { cn } from "@/shared/lib/cn" function Card({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index e536f9e..1456b68 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -4,8 +4,8 @@ import { XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; import * as React from "react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { Button } from "./button"; +import { cn } from "@/shared/lib/cn"; function Dialog({ ...props diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index f16c2c0..1f9149c 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/shared/lib/cn"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index 4c24b2a..bc615e5 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Separator as SeparatorPrimitive } from "radix-ui" -import { cn } from "@/lib/utils" +import { cn } from "@/shared/lib/cn" function Separator({ className, diff --git a/src/features/boarding/index.ts b/src/features/boarding/index.ts new file mode 100644 index 0000000..e7e5179 --- /dev/null +++ b/src/features/boarding/index.ts @@ -0,0 +1,2 @@ +export { BoardingMissionForm } from './ui/BoardingMissionForm'; +export { startVoyage } from './model/startVoyage'; diff --git a/src/features/boarding/model/startVoyage.ts b/src/features/boarding/model/startVoyage.ts new file mode 100644 index 0000000..70bc88f --- /dev/null +++ b/src/features/boarding/model/startVoyage.ts @@ -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; +}; diff --git a/src/features/boarding/ui/BoardingMissionForm.tsx b/src/features/boarding/ui/BoardingMissionForm.tsx new file mode 100644 index 0000000..8ce2db3 --- /dev/null +++ b/src/features/boarding/ui/BoardingMissionForm.tsx @@ -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 ( +
+
+ + 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} + /> +
+ +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/src/features/flight-session/model/useFlightSession.ts b/src/features/flight-session/model/useFlightSession.ts new file mode 100644 index 0000000..dacb9ee --- /dev/null +++ b/src/features/flight-session/model/useFlightSession.ts @@ -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(() => getVoyageFromStore()); + const [timeLeft, setTimeLeft] = useState(() => { + 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(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, + }; +} diff --git a/src/features/flight-starfield/index.ts b/src/features/flight-starfield/index.ts new file mode 100644 index 0000000..6c35f33 --- /dev/null +++ b/src/features/flight-starfield/index.ts @@ -0,0 +1 @@ +export { FlightStarfieldCanvas } from './ui/FlightStarfieldCanvas'; diff --git a/src/features/flight-starfield/lib/projection.ts b/src/features/flight-starfield/lib/projection.ts new file mode 100644 index 0000000..b4a4e54 --- /dev/null +++ b/src/features/flight-starfield/lib/projection.ts @@ -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, +}); diff --git a/src/features/flight-starfield/model/starfieldModel.ts b/src/features/flight-starfield/model/starfieldModel.ts new file mode 100644 index 0000000..5a33207 --- /dev/null +++ b/src/features/flight-starfield/model/starfieldModel.ts @@ -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; diff --git a/src/features/flight-starfield/model/types.ts b/src/features/flight-starfield/model/types.ts new file mode 100644 index 0000000..8075138 --- /dev/null +++ b/src/features/flight-starfield/model/types.ts @@ -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; +}; diff --git a/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx b/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx new file mode 100644 index 0000000..56747c5 --- /dev/null +++ b/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx @@ -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(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 ; +} diff --git a/src/features/lobby-session/model/useLobbyRedirect.ts b/src/features/lobby-session/model/useLobbyRedirect.ts new file mode 100644 index 0000000..41e456d --- /dev/null +++ b/src/features/lobby-session/model/useLobbyRedirect.ts @@ -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]); +} diff --git a/src/features/lobby-starfield/index.ts b/src/features/lobby-starfield/index.ts new file mode 100644 index 0000000..4822d38 --- /dev/null +++ b/src/features/lobby-starfield/index.ts @@ -0,0 +1 @@ +export { ConstellationScene } from './ui/ConstellationScene'; diff --git a/src/features/lobby-starfield/model/constellationData.ts b/src/features/lobby-starfield/model/constellationData.ts new file mode 100644 index 0000000..d932009 --- /dev/null +++ b/src/features/lobby-starfield/model/constellationData.ts @@ -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; diff --git a/src/features/lobby-starfield/ui/ConstellationScene.tsx b/src/features/lobby-starfield/ui/ConstellationScene.tsx new file mode 100644 index 0000000..770e533 --- /dev/null +++ b/src/features/lobby-starfield/ui/ConstellationScene.tsx @@ -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 ( +
+ + {constellation.segments.map((segment, segmentIndex) => ( + + ))} + + {constellation.stars.map((star, starIndex) => { + const globalStarIndex = starIndexOffset + starIndex; + + return ( + + ); + })} + +
+ ); + })} + + ); +} diff --git a/src/features/lobby-starfield/ui/StarGlint.tsx b/src/features/lobby-starfield/ui/StarGlint.tsx new file mode 100644 index 0000000..b08ec13 --- /dev/null +++ b/src/features/lobby-starfield/ui/StarGlint.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts deleted file mode 100644 index 5c94c54..0000000 --- a/src/lib/constants.ts +++ /dev/null @@ -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분 집중 항해", - }, -]; diff --git a/src/lib/utils.ts b/src/lib/utils.ts deleted file mode 100644 index bd0c391..0000000 --- a/src/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts new file mode 100644 index 0000000..1336e4f --- /dev/null +++ b/src/shared/config/routes.ts @@ -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분 집중 항해', + }, +]; diff --git a/src/shared/config/starfield.ts b/src/shared/config/starfield.ts new file mode 100644 index 0000000..d870a43 --- /dev/null +++ b/src/shared/config/starfield.ts @@ -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; diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/src/shared/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/shared/lib/math/number.ts b/src/shared/lib/math/number.ts new file mode 100644 index 0000000..5ed4d9c --- /dev/null +++ b/src/shared/lib/math/number.ts @@ -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); diff --git a/src/shared/lib/motion/prefersReducedMotion.ts b/src/shared/lib/motion/prefersReducedMotion.ts new file mode 100644 index 0000000..f2b3dbe --- /dev/null +++ b/src/shared/lib/motion/prefersReducedMotion.ts @@ -0,0 +1,4 @@ +export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + +export const getPrefersReducedMotionMediaQuery = () => + window.matchMedia(REDUCED_MOTION_QUERY); diff --git a/src/lib/store.ts b/src/shared/lib/store.ts similarity index 94% rename from src/lib/store.ts rename to src/shared/lib/store.ts index 6a6229b..6b09f2a 100644 --- a/src/lib/store.ts +++ b/src/shared/lib/store.ts @@ -1,4 +1,4 @@ -import { Voyage, UserPreferences } from "@/types"; +import { UserPreferences, Voyage } from '@/shared/types'; const KEYS = { HISTORY: 'focustella_history_v1', @@ -14,7 +14,6 @@ export const getHistory = (): Voyage[] => { export const saveToHistory = (voyage: Voyage) => { const history = getHistory(); - // Add to beginning localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history])); }; diff --git a/src/types/index.ts b/src/shared/types/index.ts similarity index 81% rename from src/types/index.ts rename to src/shared/types/index.ts index f19e094..2c23310 100644 --- a/src/types/index.ts +++ b/src/shared/types/index.ts @@ -6,7 +6,12 @@ export interface Route { description: string; } -export type VoyageStatus = 'completed' | 'partial' | 'reoriented' | 'aborted' | 'in_progress'; +export type VoyageStatus = + | 'completed' + | 'partial' + | 'reoriented' + | 'aborted' + | 'in_progress'; export interface Voyage { id: string; diff --git a/src/widgets/flight-background/index.ts b/src/widgets/flight-background/index.ts new file mode 100644 index 0000000..0a0c3fd --- /dev/null +++ b/src/widgets/flight-background/index.ts @@ -0,0 +1 @@ +export { FlightBackgroundWidget } from './ui/FlightBackgroundWidget'; diff --git a/src/widgets/flight-background/ui/FlightBackgroundWidget.tsx b/src/widgets/flight-background/ui/FlightBackgroundWidget.tsx new file mode 100644 index 0000000..3bcb15c --- /dev/null +++ b/src/widgets/flight-background/ui/FlightBackgroundWidget.tsx @@ -0,0 +1,5 @@ +import { FlightStarfieldCanvas } from '@/features/flight-starfield'; + +export function FlightBackgroundWidget() { + return ; +} diff --git a/src/widgets/flight-hud/index.ts b/src/widgets/flight-hud/index.ts new file mode 100644 index 0000000..ca6399f --- /dev/null +++ b/src/widgets/flight-hud/index.ts @@ -0,0 +1 @@ +export { FlightHudWidget } from './ui/FlightHudWidget'; diff --git a/src/widgets/flight-hud/ui/FlightHudWidget.tsx b/src/widgets/flight-hud/ui/FlightHudWidget.tsx new file mode 100644 index 0000000..50468b8 --- /dev/null +++ b/src/widgets/flight-hud/ui/FlightHudWidget.tsx @@ -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 ( + <> +
+ + {voyage.routeName} · {isPaused ? '일시정지' : '순항 중'} + +
+ +
+ {formattedTime} +
+ +
+ “{voyage.missionText}” +
+ +
+ + +
+ + ); +} diff --git a/src/widgets/lobby-background/index.ts b/src/widgets/lobby-background/index.ts new file mode 100644 index 0000000..9c7ca14 --- /dev/null +++ b/src/widgets/lobby-background/index.ts @@ -0,0 +1 @@ +export { LobbyBackgroundWidget } from './ui/LobbyBackgroundWidget'; diff --git a/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx b/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx new file mode 100644 index 0000000..2c481e8 --- /dev/null +++ b/src/widgets/lobby-background/ui/LobbyBackgroundWidget.tsx @@ -0,0 +1,9 @@ +import { ConstellationScene } from '@/features/lobby-starfield'; + +export function LobbyBackgroundWidget() { + return ( +
+ +
+ ); +} diff --git a/src/widgets/lobby-routes/index.ts b/src/widgets/lobby-routes/index.ts new file mode 100644 index 0000000..25b8f4b --- /dev/null +++ b/src/widgets/lobby-routes/index.ts @@ -0,0 +1 @@ +export { LobbyRoutesPanel } from './ui/LobbyRoutesPanel'; diff --git a/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx b/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx new file mode 100644 index 0000000..585fda3 --- /dev/null +++ b/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx @@ -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 ( +
+
+
+

+ {route.name} +

+ + {route.tag} + +
+ + + {route.durationMinutes === 0 ? '∞' : route.durationMinutes} + + {route.durationMinutes === 0 ? '' : 'min'} + + +
+ + {!isCTA && ( +

+ {route.description} +

+ )} + + {isCTA && ( +

+ {route.description} +

+ )} + + +
+ ); +} + +export function LobbyRoutesPanel() { + useLobbyRedirect(); + const router = useRouter(); + const [selectedRouteId, setSelectedRouteId] = useState(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 ( +
+
+

+ 어느 별자리로 출항할까요? +

+

몰입하기 좋은 궤도입니다.

+
+ +
+
+ +
+ +
+ {normalRoutes.map((route) => ( + + ))} +
+
+ + + + +

+ Boarding Check +

+ + {selectedRoute.name} 항로 탑승 + + + 항해를 시작하기 전에 목표를 설정하세요. + +
+ + setIsBoardingOpen(false)} + autoFocus={true} + compact={true} + /> +
+
+
+ ); +}