feat: 홈 배경 별자리 및 비행 화면 배경 생성

This commit is contained in:
2026-02-13 14:42:53 +09:00
parent 35188c7b52
commit bb1a6fbdab
7 changed files with 1047 additions and 187 deletions

2
.gitignore vendored
View File

@@ -40,4 +40,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.idea
.gemini
.cli

View File

@@ -79,7 +79,7 @@ export default function FlightPage() {
return (
<div className="flex flex-col flex-1 items-center justify-center p-6 text-white relative overflow-hidden min-h-[calc(100vh-64px)]">
<FlightBackground />
<FlightBackground vanishYOffset={-68} centerProtectRadius={200} />
{/* UI Element 1: Label */}
<div className="absolute top-8 text-center z-10">

View File

@@ -126,25 +126,119 @@
@layer utilities {
@keyframes twinkle {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 0.3; transform: scale(0.8); }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes star-core-pulse {
0%,
38%,
100% {
opacity: var(--core-low, 0.25);
}
52% {
opacity: var(--core-high, 0.55);
}
}
@keyframes star-glint-pulse {
0%,
35%,
100% {
opacity: var(--glint-base, 0.02);
}
50% {
opacity: var(--glint-peak, 0.82);
}
60% {
opacity: calc(var(--glint-peak, 0.82) * 0.12);
}
}
@keyframes star-bloom-pulse {
0%,
35%,
100% {
opacity: var(--bloom-base, 0.01);
}
50% {
opacity: var(--bloom-peak, 0.18);
}
60% {
opacity: calc(var(--bloom-peak, 0.18) * 0.2);
}
}
.animate-twinkle {
animation: twinkle 4s ease-in-out infinite;
}
.animate-twinkle-delay-1 {
animation: twinkle 5s ease-in-out infinite;
animation-delay: 1s;
.star-core {
animation-name: star-core-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--core-low, 0.25);
fill: currentColor;
}
.animate-twinkle-delay-2 {
animation: twinkle 6s ease-in-out infinite;
animation-delay: 2s;
.star-core-bloom {
animation-name: star-bloom-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--bloom-base, 0.01);
fill: currentColor;
filter: blur(0.7px);
}
.star-glint {
animation-name: star-glint-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--glint-base, 0.02);
stroke-width: 0.62;
stroke-linecap: round;
}
.star-glint-bloom {
animation-name: star-bloom-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--bloom-base, 0.01);
stroke-width: 0.9;
stroke-linecap: round;
filter: blur(0.65px);
}
@media (prefers-reduced-motion: reduce) {
.animate-twinkle, .animate-twinkle-delay-1, .animate-twinkle-delay-2 {
.animate-twinkle {
animation: none;
opacity: 0.8;
}
.star-core {
animation: none;
opacity: var(--core-reduced, 0.34);
}
.star-core-bloom {
animation: none;
opacity: 0.08;
}
.star-glint {
animation: none;
opacity: 0.08;
}
.star-glint-bloom {
animation: none;
opacity: 0.04;
}
}
}

View File

@@ -1,63 +1,370 @@
'use client';
"use client";
import Link from "next/link";
import { ROUTES } from "@/lib/constants";
import { useEffect, useState } from "react";
import { getCurrentVoyage } from "@/lib/store";
import { Route } from "@/types";
import Link from "next/link";
import { useRouter } from "next/navigation";
import LobbyBackground from "@/components/LobbyBackground";
import { CSSProperties, useEffect } from "react";
export default function Home() {
const router = useRouter();
const [isMount, setIsMount] = useState(false);
type Star = {
cx: number;
cy: number;
r: number;
armScale?: number;
};
useEffect(() => {
setIsMount(true);
const current = getCurrentVoyage();
if (current && current.status === 'in_progress') {
router.replace('/flight');
}
}, [router]);
type Segment = {
x1: number;
y1: number;
x2: number;
y2: number;
};
if (!isMount) return null;
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 (
<div className="flex flex-col flex-1 relative animate-in fade-in duration-500">
{/* Background Layer */}
<LobbyBackground />
{/* Content Layer */}
<div className="relative z-10 flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-6 md:p-12">
<div className="space-y-4 text-center mb-12 max-w-2xl mx-auto backdrop-blur-sm bg-slate-950/30 p-6 rounded-3xl border border-slate-800/30">
<h1 className="text-3xl md:text-5xl font-bold text-slate-100 tracking-tight"> ?</h1>
<p className="text-slate-300 text-lg"> .</p>
</div>
<div className="grid gap-6 w-full max-w-5xl grid-cols-1 md:grid-cols-2 lg:grid-cols-3 place-items-stretch">
{ROUTES.map((route) => (
<div key={route.id} className="group relative bg-slate-900/60 backdrop-blur-md hover:bg-slate-800/80 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-6 transition-all duration-300 flex flex-col h-full shadow-lg hover:shadow-indigo-900/20">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-bold text-xl text-indigo-100 group-hover:text-white transition-colors">{route.name}</h3>
<span className="text-xs font-medium text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded-full border border-slate-800 mt-1 inline-block">
{route.tag}
</span>
</div>
<span className="text-3xl font-light text-slate-300 group-hover:text-white transition-colors">{route.durationMinutes}<span className="text-sm ml-1 text-slate-500">min</span></span>
</div>
<p className="text-sm text-slate-400 mb-8 flex-1 leading-relaxed">{route.description}</p>
<Link
href={`/boarding?routeId=${route.id}`}
className="flex w-full items-center justify-center py-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/30 active:scale-[0.98]"
<g>
<defs>
<linearGradient
id={gradientXId}
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
gradientUnits="userSpaceOnUse"
>
</Link>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient
id={gradientYId}
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<circle
cx={star.cx}
cy={star.cy}
r={star.r}
className="star-core"
style={coreStyle}
/>
<circle
cx={star.cx}
cy={star.cy}
r={star.r * 1.9}
className="star-core-bloom"
style={coreStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint-bloom"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint-bloom"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
</g>
);
}
function LobbyBackground() {
return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
{CONSTELLATIONS.map((constellation, constellationIndex) => (
<div key={constellation.key} className={constellation.className}>
<svg
viewBox={constellation.viewBox}
className={`w-full h-full fill-current ${constellation.colorClass}`}
>
{constellation.segments.map((segment, segmentIndex) => (
<line
key={`${constellation.key}-segment-${segmentIndex}`}
x1={segment.x1}
y1={segment.y1}
x2={segment.x2}
y2={segment.y2}
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
))}
{constellation.stars.map((star, starIndex) => {
const globalStarIndex =
CONSTELLATIONS.slice(0, constellationIndex).reduce(
(sum, item) => sum + item.stars.length,
0,
) + starIndex;
return (
<StarGlint
key={`${constellation.key}-star-${globalStarIndex}`}
starIndex={globalStarIndex}
star={star}
/>
);
})}
</svg>
</div>
))}
</div>
);
}
<div className="mt-16 text-center py-6 text-xs text-slate-500 font-medium tracking-wide uppercase">
3
function RouteCard({
route,
isCTA = false,
}: {
route: Route;
isCTA?: boolean;
}) {
return (
<div
className={`group relative bg-slate-900/60 backdrop-blur-md hover:bg-slate-800/80 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-6 transition-all duration-300 flex flex-col h-full shadow-lg hover:shadow-indigo-900/20 ${isCTA ? "min-h-[200px] justify-center items-center text-center" : ""}`}
>
<div
className={`flex w-full relative z-10 ${isCTA ? "flex-col items-center gap-4" : "justify-between items-start mb-4"}`}
>
<div className={isCTA ? "flex flex-col items-center" : ""}>
<h3
className={`font-bold text-indigo-100 group-hover:text-white transition-colors ${isCTA ? "text-3xl" : "text-xl"}`}
>
{route.name}
</h3>
<span className="text-xs font-medium text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded-full border border-slate-800 mt-2 inline-block">
{route.tag}
</span>
</div>
<span
className={`font-light text-slate-300 group-hover:text-white transition-colors ${isCTA ? "text-4xl mt-2" : "text-3xl"}`}
>
{route.durationMinutes === 0 ? "∞" : route.durationMinutes}
<span className="text-sm ml-1 text-slate-500">
{route.durationMinutes === 0 ? "" : "min"}
</span>
</span>
</div>
{!isCTA && (
<p className="text-sm text-slate-400 mb-8 flex-1 leading-relaxed relative z-10 text-left w-full">
{route.description}
</p>
)}
{isCTA && (
<p className="text-base text-slate-400 mb-6 relative z-10 max-w-lg">
{route.description}
</p>
)}
<Link
href={`/boarding?routeId=${route.id}`}
className={`flex items-center justify-center bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/30 active:scale-[0.98] relative z-10 ${isCTA ? "w-full max-w-md py-4 text-lg" : "w-full py-4"}`}
>
{isCTA ? "정거장 진입 (대기)" : "바로 출항"}
</Link>
</div>
);
}
export default function Home() {
const router = useRouter();
useEffect(() => {
const current = getCurrentVoyage();
if (current && current.status === "in_progress") {
router.replace("/flight");
}
}, [router]);
const stationRoute = ROUTES[0];
const normalRoutes = ROUTES.slice(1);
return (
<div className="flex flex-col flex-1 relative animate-in fade-in duration-500">
<LobbyBackground />
<div className="relative z-10 flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-6 md:p-12">
<div className="space-y-4 text-center mb-12 max-w-2xl mx-auto backdrop-blur-sm bg-slate-950/30 p-6 rounded-3xl border border-slate-800/30">
<h1 className="text-3xl md:text-5xl font-bold text-slate-100 tracking-tight">
?
</h1>
<p className="text-slate-300 text-lg"> .</p>
</div>
<div className="flex flex-col gap-6 w-full max-w-4xl mx-auto">
<div className="w-full">
<RouteCard route={stationRoute} isCTA={true} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
{normalRoutes.map((route) => (
<RouteCard key={route.id} route={route} />
))}
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,31 @@
import { useEffect, useRef } from 'react';
export default function FlightBackground() {
type FlightBackgroundProps = {
vanishYOffset?: number;
centerProtectRadius?: number;
};
type Star = {
wx: number;
wy: number;
z: number;
speed: number;
radius: number;
alpha: number;
tailLength: number;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const randomInRange = (min: number, max: number) =>
min + Math.random() * (max - min);
export default function FlightBackground({
vanishYOffset = -64,
centerProtectRadius = 190,
}: FlightBackgroundProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
@@ -14,9 +38,12 @@ export default function FlightBackground() {
let width = window.innerWidth;
let height = window.innerHeight;
let animationFrameId = 0;
// Check prefers-reduced-motion
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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;
@@ -24,103 +51,262 @@ export default function FlightBackground() {
canvas.width = width;
canvas.height = height;
};
setSize();
window.addEventListener('resize', setSize);
// Star configuration
const starCount = 150;
const stars: { x: number; y: number; z: number; speed: number }[] = [];
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);
};
// Target point (slightly above center)
// We will move stars away from this point.
// Instead of full 3D, let's do a 2D split flow.
// Stars spawn randomly.
// If x < center, move left-down.
// If x > center, move right-down.
// Initial Spawn
for (let i = 0; i < starCount; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height,
z: Math.random() * 2 + 0.5, // depth/size
speed: Math.random() * 2 + 0.5
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);
}
let animationFrameId: number;
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 = () => {
if (!ctx) return;
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; // Trails
ctx.fillRect(0, 0, width, height);
// ctx.clearRect(0, 0, width, height); // Clear
const targetX = width / 2;
// targetY is virtual, above screen.
// Movement vector:
// Left side: dx = -speed, dy = speed
// Right side: dx = speed, dy = speed
ctx.fillStyle = '#ffffff';
stars.forEach((star) => {
// Reduced motion: very slow or static
const moveFactor = prefersReduced ? 0.1 : 1.0;
// Determine direction based on side
const isLeft = star.x < targetX;
// Simple perspective-ish flow
// The further from center X, the faster dx
// The further down, the faster dy
// Let's stick to the PRD requirements:
// Left -> Bottom-Left
// Right -> Bottom-Right
const dx = (isLeft ? -1 : 1) * star.speed * 0.5 * moveFactor;
const dy = star.speed * moveFactor;
star.x += dx;
star.y += dy;
// Reset if out of bounds
if (star.y > height || star.x < 0 || star.x > width) {
star.y = -10;
star.x = Math.random() * width;
// Bias respawn near center-top for "flow" effect?
// Or just random top. Random top is safer for uniform coverage.
}
const size = Math.max(0.5, star.z * (star.y / height) * 2);
const opacity = Math.min(1, star.y / 200); // Fade in from top
ctx.globalAlpha = opacity;
ctx.beginPath();
ctx.arc(star.x, star.y, size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
});
// Draw subtle cockpit frame or vignette
// Gradient vignette
const gradient = ctx.createRadialGradient(width/2, height/2, height/3, width/2, height/2, height);
gradient.addColorStop(0, 'transparent');
gradient.addColorStop(1, 'rgba(0,0,0,0.8)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
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', setSize);
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', handleMotionChange);
cancelAnimationFrame(animationFrameId);
};
}, []);
}, [vanishYOffset, centerProtectRadius]);
return <canvas ref={canvasRef} className="fixed inset-0 z-0 pointer-events-none bg-black" />;
}

View File

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

View File

@@ -2,24 +2,24 @@ import { Route } from "@/types";
export const ROUTES: Route[] = [
{
id: 'orion',
name: '오리온',
durationMinutes: 180,
tag: '딥워크',
description: '집필, 코딩 등 긴 호흡이 필요한 작업'
id: "station",
name: "우주정거장",
durationMinutes: 0, // 0 implies unlimited
tag: "대기/자유",
description: "시간 제한 없이 머무를 수 있는 안전지대",
},
{
id: 'lyra',
name: '거문고',
id: "orion",
name: "오리온",
durationMinutes: 60,
tag: '정리/기획',
description: '기획안 작성, 문서 정리'
tag: "딥워크",
description: "60분 집중 항해",
},
{
id: 'cygnus',
name: '백조',
id: "gemini",
name: "쌍둥이자리",
durationMinutes: 30,
tag: '리뷰/회고',
description: '하루 회고, 코드 리뷰'
tag: "숏스프린트",
description: "30분 집중 항해",
},
];