-
-
- {/* 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) => (
-
-
-
- ))}
-
- );
-}
-
-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 */}
-
-
-
-
- {/* Big Dipper (북두칠성) - Top Left */}
-
-
-
-
- );
-}
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 (
+
+
+
+ );
+ })}
+ >
+ );
+}
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) => (
+
+ ))}
+
+
+
+
+
+ );
+}