refactor: fsd 구조로 변환
This commit is contained in:
2
src/features/boarding/index.ts
Normal file
2
src/features/boarding/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BoardingMissionForm } from './ui/BoardingMissionForm';
|
||||
export { startVoyage } from './model/startVoyage';
|
||||
33
src/features/boarding/model/startVoyage.ts
Normal file
33
src/features/boarding/model/startVoyage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Route, Voyage } from '@/shared/types';
|
||||
import { saveCurrentVoyage } from '@/shared/lib/store';
|
||||
|
||||
const createVoyageId = () =>
|
||||
(crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.slice(0, 16);
|
||||
|
||||
export const startVoyage = ({
|
||||
route,
|
||||
mission,
|
||||
}: {
|
||||
route: Route;
|
||||
mission: string;
|
||||
}) => {
|
||||
const missionText = mission.trim();
|
||||
if (!missionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newVoyage: Voyage = {
|
||||
id: createVoyageId(),
|
||||
routeId: route.id,
|
||||
routeName: route.name,
|
||||
durationMinutes: route.durationMinutes,
|
||||
startedAt: Date.now(),
|
||||
status: 'in_progress',
|
||||
missionText,
|
||||
};
|
||||
|
||||
saveCurrentVoyage(newVoyage);
|
||||
return true;
|
||||
};
|
||||
54
src/features/boarding/ui/BoardingMissionForm.tsx
Normal file
54
src/features/boarding/ui/BoardingMissionForm.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function BoardingMissionForm({
|
||||
onDock,
|
||||
onCancel,
|
||||
autoFocus = false,
|
||||
compact = false,
|
||||
}: {
|
||||
onDock: (mission: string) => void;
|
||||
onCancel?: () => void;
|
||||
autoFocus?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [mission, setMission] = useState('');
|
||||
const trimmedMission = mission.trim();
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${compact ? 'gap-6' : 'space-y-8 flex-1'}`}>
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-300">
|
||||
이번 항해의 핵심 목표
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mission}
|
||||
onChange={(event) => setMission(event.target.value)}
|
||||
placeholder="예: 서론 3문단 완성하기"
|
||||
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`flex ${compact ? 'justify-end gap-3' : 'mt-8 flex-col gap-3'} w-full`}>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-xl border border-slate-700 bg-transparent px-4 py-2 font-semibold text-slate-300 transition-colors hover:bg-slate-900/60 hover:text-white"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDock(trimmedMission)}
|
||||
disabled={!trimmedMission}
|
||||
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
|
||||
>
|
||||
도킹 완료 (출항)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/features/flight-session/model/useFlightSession.ts
Normal file
100
src/features/flight-session/model/useFlightSession.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { getCurrentVoyage, getPreferences, saveCurrentVoyage } from '@/shared/lib/store';
|
||||
import { Voyage } from '@/shared/types';
|
||||
|
||||
const getVoyageFromStore = () => {
|
||||
const current = getCurrentVoyage();
|
||||
if (!current || current.status !== 'in_progress') {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const getEndTime = (voyage: Voyage | null) => {
|
||||
if (!voyage) return 0;
|
||||
return voyage.startedAt + voyage.durationMinutes * 60 * 1000;
|
||||
};
|
||||
|
||||
export function useFlightSession() {
|
||||
const router = useRouter();
|
||||
const [voyage] = useState<Voyage | null>(() => getVoyageFromStore());
|
||||
const [timeLeft, setTimeLeft] = useState<number>(() => {
|
||||
const current = getVoyageFromStore();
|
||||
const endTime = getEndTime(current);
|
||||
if (!endTime) return 0;
|
||||
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
|
||||
});
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [hideSeconds] = useState(() => getPreferences().hideSeconds);
|
||||
const endTimeRef = useRef<number>(getEndTime(getVoyageFromStore()));
|
||||
|
||||
useEffect(() => {
|
||||
if (voyage) return;
|
||||
|
||||
router.replace('/');
|
||||
}, [voyage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voyage || isPaused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const diff = endTimeRef.current - Date.now();
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeLeft(0);
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeLeft(Math.ceil(diff / 1000));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [voyage, isPaused]);
|
||||
|
||||
const handlePauseToggle = () => {
|
||||
if (isPaused) {
|
||||
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||
setIsPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPaused(true);
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
if (!voyage) return;
|
||||
|
||||
const endedVoyage: Voyage = {
|
||||
...voyage,
|
||||
endedAt: Date.now(),
|
||||
};
|
||||
|
||||
saveCurrentVoyage(endedVoyage);
|
||||
router.push('/debrief');
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (hideSeconds) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return {
|
||||
voyage,
|
||||
timeLeft,
|
||||
isPaused,
|
||||
formattedTime: formatTime(timeLeft),
|
||||
handlePauseToggle,
|
||||
handleFinish,
|
||||
};
|
||||
}
|
||||
1
src/features/flight-starfield/index.ts
Normal file
1
src/features/flight-starfield/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FlightStarfieldCanvas } from './ui/FlightStarfieldCanvas';
|
||||
21
src/features/flight-starfield/lib/projection.ts
Normal file
21
src/features/flight-starfield/lib/projection.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FlightStar, VanishingPoint } from '@/features/flight-starfield/model/types';
|
||||
|
||||
export const createVanishingPoint = ({
|
||||
width,
|
||||
height,
|
||||
xJitter,
|
||||
yOffset,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
xJitter: number;
|
||||
yOffset: number;
|
||||
}): VanishingPoint => ({
|
||||
x: width / 2 + xJitter,
|
||||
y: height / 2 + yOffset,
|
||||
});
|
||||
|
||||
export const projectFlightStar = (star: FlightStar, vp: VanishingPoint, zValue: number) => ({
|
||||
x: vp.x + star.wx / zValue,
|
||||
y: vp.y + star.wy / zValue,
|
||||
});
|
||||
175
src/features/flight-starfield/model/starfieldModel.ts
Normal file
175
src/features/flight-starfield/model/starfieldModel.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { FLIGHT_STARFIELD_TUNING } from '@/shared/config/starfield';
|
||||
import { clamp, randomInRange } from '@/shared/lib/math/number';
|
||||
|
||||
import { FlightStar } from '@/features/flight-starfield/model/types';
|
||||
|
||||
export const getFlightStarCount = (width: number, height: number) => {
|
||||
const isMobile = width < FLIGHT_STARFIELD_TUNING.mobileBreakpoint;
|
||||
const min = isMobile
|
||||
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
|
||||
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
|
||||
const max = isMobile
|
||||
? FLIGHT_STARFIELD_TUNING.starCount.mobile.max
|
||||
: FLIGHT_STARFIELD_TUNING.starCount.desktop.max;
|
||||
const byArea = Math.round((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor);
|
||||
|
||||
return clamp(byArea, min, max);
|
||||
};
|
||||
|
||||
export const createFlightVanishXJitter = () => {
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
return (
|
||||
sign *
|
||||
randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.vanishXJitter.min,
|
||||
FLIGHT_STARFIELD_TUNING.vanishXJitter.max,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const createFlightSpeed = () => {
|
||||
const tier = Math.random();
|
||||
|
||||
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.slow.chance) {
|
||||
return randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.speedTiers.slow.min,
|
||||
FLIGHT_STARFIELD_TUNING.speedTiers.slow.max,
|
||||
);
|
||||
}
|
||||
|
||||
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.medium.chance) {
|
||||
return randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.speedTiers.medium.min,
|
||||
FLIGHT_STARFIELD_TUNING.speedTiers.medium.max,
|
||||
);
|
||||
}
|
||||
|
||||
return randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.speedTiers.fast.min,
|
||||
FLIGHT_STARFIELD_TUNING.speedTiers.fast.max,
|
||||
);
|
||||
};
|
||||
|
||||
const createFlightVisualTier = () => {
|
||||
const highlight = Math.random() < FLIGHT_STARFIELD_TUNING.radius.highlightChance;
|
||||
const tailRoll = Math.random();
|
||||
const tailLength =
|
||||
tailRoll < FLIGHT_STARFIELD_TUNING.tail.pointChance
|
||||
? randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.tail.pointRange.min,
|
||||
FLIGHT_STARFIELD_TUNING.tail.pointRange.max,
|
||||
)
|
||||
: tailRoll < FLIGHT_STARFIELD_TUNING.tail.shortChance
|
||||
? randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.tail.shortRange.min,
|
||||
FLIGHT_STARFIELD_TUNING.tail.shortRange.max,
|
||||
)
|
||||
: randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.tail.longRange.min,
|
||||
FLIGHT_STARFIELD_TUNING.tail.longRange.max,
|
||||
);
|
||||
|
||||
return {
|
||||
radius: highlight
|
||||
? randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.radius.highlight.min,
|
||||
FLIGHT_STARFIELD_TUNING.radius.highlight.max,
|
||||
)
|
||||
: randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.radius.normal.min,
|
||||
FLIGHT_STARFIELD_TUNING.radius.normal.max,
|
||||
),
|
||||
alpha: highlight
|
||||
? randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.alpha.highlight.min,
|
||||
FLIGHT_STARFIELD_TUNING.alpha.highlight.max,
|
||||
)
|
||||
: randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.alpha.normal.min,
|
||||
FLIGHT_STARFIELD_TUNING.alpha.normal.max,
|
||||
),
|
||||
tailLength,
|
||||
};
|
||||
};
|
||||
|
||||
const createFlightSpawnRadius = (width: number, height: number) => {
|
||||
const roll = Math.random();
|
||||
const maxWideRadius = Math.min(
|
||||
Math.max(width, height) * FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxScaleOfViewport,
|
||||
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxAbsolute,
|
||||
);
|
||||
const ringOuter = Math.min(
|
||||
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.max,
|
||||
maxWideRadius,
|
||||
);
|
||||
|
||||
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.centerChance) {
|
||||
return randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.min,
|
||||
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.max,
|
||||
);
|
||||
}
|
||||
|
||||
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.ringChance) {
|
||||
return randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min,
|
||||
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min, ringOuter),
|
||||
);
|
||||
}
|
||||
|
||||
return randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min,
|
||||
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min, maxWideRadius),
|
||||
);
|
||||
};
|
||||
|
||||
export const createFlightStar = ({
|
||||
width,
|
||||
height,
|
||||
isRespawn,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
isRespawn: boolean;
|
||||
}): FlightStar => {
|
||||
const radius = createFlightSpawnRadius(width, height);
|
||||
const angle = randomInRange(0, Math.PI * 2);
|
||||
const visuals = createFlightVisualTier();
|
||||
|
||||
return {
|
||||
wx: Math.cos(angle) * radius,
|
||||
wy: Math.sin(angle) * radius,
|
||||
z: isRespawn
|
||||
? randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.zRange.respawn.min,
|
||||
FLIGHT_STARFIELD_TUNING.zRange.respawn.max,
|
||||
)
|
||||
: randomInRange(
|
||||
FLIGHT_STARFIELD_TUNING.zRange.initial.min,
|
||||
FLIGHT_STARFIELD_TUNING.zRange.initial.max,
|
||||
),
|
||||
speed: createFlightSpeed(),
|
||||
radius: visuals.radius,
|
||||
alpha: visuals.alpha,
|
||||
tailLength: visuals.tailLength,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldRecycleFlightStar = ({
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) =>
|
||||
z <= FLIGHT_STARFIELD_TUNING.zRange.recycleThreshold ||
|
||||
x < -50 ||
|
||||
x > width + 50 ||
|
||||
y < -50 ||
|
||||
y > height + 50;
|
||||
14
src/features/flight-starfield/model/types.ts
Normal file
14
src/features/flight-starfield/model/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type FlightStar = {
|
||||
wx: number;
|
||||
wy: number;
|
||||
z: number;
|
||||
speed: number;
|
||||
radius: number;
|
||||
alpha: number;
|
||||
tailLength: number;
|
||||
};
|
||||
|
||||
export type VanishingPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
249
src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
Normal file
249
src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { projectFlightStar, createVanishingPoint } from '@/features/flight-starfield/lib/projection';
|
||||
import {
|
||||
createFlightStar,
|
||||
createFlightVanishXJitter,
|
||||
getFlightStarCount,
|
||||
shouldRecycleFlightStar,
|
||||
} from '@/features/flight-starfield/model/starfieldModel';
|
||||
import { FlightStar } from '@/features/flight-starfield/model/types';
|
||||
import { clamp } from '@/shared/lib/math/number';
|
||||
import { getPrefersReducedMotionMediaQuery } from '@/shared/lib/motion/prefersReducedMotion';
|
||||
|
||||
export function FlightStarfieldCanvas({
|
||||
vanishYOffset = -68,
|
||||
centerProtectRadius = 200,
|
||||
}: {
|
||||
vanishYOffset?: number;
|
||||
centerProtectRadius?: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
let width = window.innerWidth;
|
||||
let height = window.innerHeight;
|
||||
let animationFrameId = 0;
|
||||
|
||||
const motionQuery = getPrefersReducedMotionMediaQuery();
|
||||
let prefersReducedMotion = motionQuery.matches;
|
||||
const vanishXJitter = createFlightVanishXJitter();
|
||||
|
||||
const setCanvasSize = () => {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
};
|
||||
|
||||
const getVanishingPoint = () =>
|
||||
createVanishingPoint({
|
||||
width,
|
||||
height,
|
||||
xJitter: vanishXJitter,
|
||||
yOffset: vanishYOffset,
|
||||
});
|
||||
|
||||
const createStars = () =>
|
||||
Array.from({ length: getFlightStarCount(width, height) }, () =>
|
||||
createFlightStar({ width, height, isRespawn: false }),
|
||||
);
|
||||
|
||||
setCanvasSize();
|
||||
let stars: FlightStar[] = createStars();
|
||||
|
||||
const applyCenterProtection = (x: number, y: number, alpha: number) => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const distance = Math.hypot(x - centerX, y - centerY);
|
||||
|
||||
if (distance >= centerProtectRadius) return alpha;
|
||||
|
||||
const ratio = clamp(distance / centerProtectRadius, 0, 1);
|
||||
const attenuation = 0.35 + ratio * 0.65;
|
||||
return alpha * attenuation;
|
||||
};
|
||||
|
||||
const drawStar = ({
|
||||
star,
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}: {
|
||||
star: FlightStar;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}) => {
|
||||
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
|
||||
const deltaX = toX - fromX;
|
||||
const deltaY = toY - fromY;
|
||||
const movementLength = Math.hypot(deltaX, deltaY);
|
||||
|
||||
if (star.tailLength < 1 || movementLength < 0.001) {
|
||||
context.globalAlpha = visibleAlpha;
|
||||
context.fillStyle = '#f8fbff';
|
||||
context.beginPath();
|
||||
context.arc(toX, toY, star.radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const directionX = deltaX / movementLength;
|
||||
const directionY = deltaY / movementLength;
|
||||
const tailX = toX - directionX * star.tailLength;
|
||||
const tailY = toY - directionY * star.tailLength;
|
||||
|
||||
const gradient = context.createLinearGradient(tailX, tailY, toX, toY);
|
||||
gradient.addColorStop(0, 'rgba(248, 251, 255, 0)');
|
||||
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
|
||||
|
||||
context.strokeStyle = gradient;
|
||||
context.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6);
|
||||
context.beginPath();
|
||||
context.moveTo(tailX, tailY);
|
||||
context.lineTo(toX, toY);
|
||||
context.stroke();
|
||||
|
||||
context.globalAlpha = Math.min(1, visibleAlpha + 0.08);
|
||||
context.fillStyle = '#f8fbff';
|
||||
context.beginPath();
|
||||
context.arc(toX, toY, clamp(star.radius * 0.72, 0.6, 1.45), 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const drawCenterVeil = () => {
|
||||
const vp = getVanishingPoint();
|
||||
const veil = context.createRadialGradient(
|
||||
vp.x,
|
||||
vp.y,
|
||||
centerProtectRadius * 0.18,
|
||||
vp.x,
|
||||
vp.y,
|
||||
centerProtectRadius * 1.35,
|
||||
);
|
||||
veil.addColorStop(0, 'rgba(160, 185, 235, 0.08)');
|
||||
veil.addColorStop(0.55, 'rgba(90, 114, 170, 0.03)');
|
||||
veil.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
context.fillStyle = veil;
|
||||
context.fillRect(0, 0, width, height);
|
||||
};
|
||||
|
||||
const drawVignette = () => {
|
||||
const vignette = context.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.min(width, height) * 0.25,
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.max(width, height) * 0.95,
|
||||
);
|
||||
vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
||||
vignette.addColorStop(1, 'rgba(0, 0, 0, 0.82)');
|
||||
context.fillStyle = vignette;
|
||||
context.fillRect(0, 0, width, height);
|
||||
};
|
||||
|
||||
const drawFrame = (moveStars: boolean) => {
|
||||
context.fillStyle = 'rgba(2, 5, 10, 0.3)';
|
||||
context.fillRect(0, 0, width, height);
|
||||
|
||||
drawCenterVeil();
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
const vp = getVanishingPoint();
|
||||
const from = projectFlightStar(star, vp, star.z);
|
||||
|
||||
if (moveStars) {
|
||||
star.z -= star.speed;
|
||||
}
|
||||
|
||||
const to = projectFlightStar(star, vp, star.z);
|
||||
|
||||
if (
|
||||
shouldRecycleFlightStar({
|
||||
x: to.x,
|
||||
y: to.y,
|
||||
z: star.z,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
) {
|
||||
stars[index] = createFlightStar({ width, height, isRespawn: true });
|
||||
return;
|
||||
}
|
||||
|
||||
drawStar({
|
||||
star,
|
||||
fromX: from.x,
|
||||
fromY: from.y,
|
||||
toX: to.x,
|
||||
toY: to.y,
|
||||
});
|
||||
});
|
||||
|
||||
drawVignette();
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
drawFrame(true);
|
||||
animationFrameId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
const renderStatic = () => {
|
||||
context.clearRect(0, 0, width, height);
|
||||
drawFrame(false);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
setCanvasSize();
|
||||
stars = createStars();
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
renderStatic();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMotionChange = (event: MediaQueryListEvent) => {
|
||||
prefersReducedMotion = event.matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
renderStatic();
|
||||
return;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
motionQuery.addEventListener('change', handleMotionChange);
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
renderStatic();
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
motionQuery.removeEventListener('change', handleMotionChange);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [vanishYOffset, centerProtectRadius]);
|
||||
|
||||
return <canvas ref={canvasRef} className="fixed inset-0 z-0 bg-black pointer-events-none" />;
|
||||
}
|
||||
15
src/features/lobby-session/model/useLobbyRedirect.ts
Normal file
15
src/features/lobby-session/model/useLobbyRedirect.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { getCurrentVoyage } from '@/shared/lib/store';
|
||||
|
||||
export function useLobbyRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const current = getCurrentVoyage();
|
||||
if (current && current.status === 'in_progress') {
|
||||
router.replace('/flight');
|
||||
}
|
||||
}, [router]);
|
||||
}
|
||||
1
src/features/lobby-starfield/index.ts
Normal file
1
src/features/lobby-starfield/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ConstellationScene } from './ui/ConstellationScene';
|
||||
105
src/features/lobby-starfield/model/constellationData.ts
Normal file
105
src/features/lobby-starfield/model/constellationData.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export type LobbyStar = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
armScale?: number;
|
||||
};
|
||||
|
||||
export type LobbySegment = {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
};
|
||||
|
||||
export type LobbyConstellation = {
|
||||
key: 'orion' | 'auriga' | 'ursaMajor';
|
||||
className: string;
|
||||
viewBox: string;
|
||||
colorClass: string;
|
||||
stars: LobbyStar[];
|
||||
segments: LobbySegment[];
|
||||
};
|
||||
|
||||
export const LOBBY_CONSTELLATIONS: LobbyConstellation[] = [
|
||||
{
|
||||
key: 'orion',
|
||||
className: 'absolute bottom-10 left-5 w-72 h-72 opacity-30',
|
||||
viewBox: '0 0 100 100',
|
||||
colorClass: 'text-indigo-200',
|
||||
stars: [
|
||||
{ cx: 25, cy: 15, r: 0.82 },
|
||||
{ cx: 75, cy: 25, r: 0.96, armScale: 1.08 },
|
||||
{ cx: 45, cy: 48, r: 0.58 },
|
||||
{ cx: 50, cy: 50, r: 0.54 },
|
||||
{ cx: 55, cy: 52, r: 0.6 },
|
||||
{ cx: 30, cy: 85, r: 0.86 },
|
||||
{ cx: 70, cy: 80, r: 0.98, armScale: 1.08 },
|
||||
],
|
||||
segments: [
|
||||
{ x1: 25, y1: 15, x2: 45, y2: 48 },
|
||||
{ x1: 75, y1: 25, x2: 55, y2: 52 },
|
||||
{ x1: 45, y1: 48, x2: 30, y2: 85 },
|
||||
{ x1: 55, y1: 52, x2: 70, y2: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auriga',
|
||||
className: 'absolute top-10 right-10 w-64 h-64 opacity-25',
|
||||
viewBox: '0 0 100 100',
|
||||
colorClass: 'text-blue-200',
|
||||
stars: [
|
||||
{ cx: 50, cy: 15, r: 1.06, armScale: 1.1 },
|
||||
{ cx: 20, cy: 35, r: 0.67 },
|
||||
{ cx: 25, cy: 75, r: 0.65 },
|
||||
{ cx: 75, cy: 75, r: 0.66 },
|
||||
{ cx: 85, cy: 35, r: 0.76 },
|
||||
],
|
||||
segments: [
|
||||
{ x1: 50, y1: 15, x2: 20, y2: 35 },
|
||||
{ x1: 20, y1: 35, x2: 25, y2: 75 },
|
||||
{ x1: 25, y1: 75, x2: 75, y2: 75 },
|
||||
{ x1: 75, y1: 75, x2: 85, y2: 35 },
|
||||
{ x1: 85, y1: 35, x2: 50, y2: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ursaMajor',
|
||||
className: 'absolute top-20 left-10 w-80 h-48 opacity-25',
|
||||
viewBox: '0 0 100 60',
|
||||
colorClass: 'text-slate-200',
|
||||
stars: [
|
||||
{ cx: 10, cy: 20, r: 0.64 },
|
||||
{ cx: 25, cy: 25, r: 0.67 },
|
||||
{ cx: 40, cy: 35, r: 0.69 },
|
||||
{ cx: 55, cy: 45, r: 0.99 },
|
||||
{ cx: 75, cy: 45, r: 0.98 },
|
||||
{ cx: 80, cy: 15, r: 0.93 },
|
||||
{ cx: 60, cy: 10, r: 0.96 },
|
||||
],
|
||||
segments: [
|
||||
{ x1: 10, y1: 20, x2: 25, y2: 25 },
|
||||
{ x1: 25, y1: 25, x2: 40, y2: 35 },
|
||||
{ x1: 40, y1: 35, x2: 55, y2: 45 },
|
||||
{ x1: 55, y1: 45, x2: 75, y2: 45 },
|
||||
{ x1: 75, y1: 45, x2: 80, y2: 15 },
|
||||
{ x1: 80, y1: 15, x2: 60, y2: 10 },
|
||||
{ x1: 60, y1: 10, x2: 55, y2: 45 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const LOBBY_STAR_TIMINGS = [
|
||||
{ duration: 2.3, delay: 0.1 },
|
||||
{ duration: 3.1, delay: 1.2 },
|
||||
{ duration: 4.8, delay: 0.8 },
|
||||
{ duration: 2.7, delay: 2.1 },
|
||||
{ duration: 5.2, delay: 1.7 },
|
||||
{ duration: 3.9, delay: 0.4 },
|
||||
{ duration: 4.4, delay: 2.6 },
|
||||
{ duration: 2.1, delay: 1.3 },
|
||||
{ duration: 5.8, delay: 0.2 },
|
||||
{ duration: 3.3, delay: 2.4 },
|
||||
{ duration: 4.0, delay: 1.1 },
|
||||
{ duration: 2.9, delay: 1.9 },
|
||||
] as const;
|
||||
49
src/features/lobby-starfield/ui/ConstellationScene.tsx
Normal file
49
src/features/lobby-starfield/ui/ConstellationScene.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { LOBBY_CONSTELLATIONS } from '@/features/lobby-starfield/model/constellationData';
|
||||
import { StarGlint } from './StarGlint';
|
||||
|
||||
export function ConstellationScene() {
|
||||
return (
|
||||
<>
|
||||
{LOBBY_CONSTELLATIONS.map((constellation, constellationIndex) => {
|
||||
const starIndexOffset = LOBBY_CONSTELLATIONS.slice(0, constellationIndex).reduce(
|
||||
(sum, item) => sum + item.stars.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={constellation.key} className={constellation.className}>
|
||||
<svg
|
||||
viewBox={constellation.viewBox}
|
||||
className={`h-full w-full fill-current ${constellation.colorClass}`}
|
||||
>
|
||||
{constellation.segments.map((segment, segmentIndex) => (
|
||||
<line
|
||||
key={`${constellation.key}-segment-${segmentIndex}`}
|
||||
x1={segment.x1}
|
||||
y1={segment.y1}
|
||||
x2={segment.x2}
|
||||
y2={segment.y2}
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
))}
|
||||
|
||||
{constellation.stars.map((star, starIndex) => {
|
||||
const globalStarIndex = starIndexOffset + starIndex;
|
||||
|
||||
return (
|
||||
<StarGlint
|
||||
key={`${constellation.key}-star-${globalStarIndex}`}
|
||||
starIndex={globalStarIndex}
|
||||
star={star}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
src/features/lobby-starfield/ui/StarGlint.tsx
Normal file
123
src/features/lobby-starfield/ui/StarGlint.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { LOBBY_STAR_TIMINGS, LobbyStar } from '@/features/lobby-starfield/model/constellationData';
|
||||
|
||||
export function StarGlint({
|
||||
star,
|
||||
starIndex,
|
||||
}: {
|
||||
star: LobbyStar;
|
||||
starIndex: number;
|
||||
}) {
|
||||
const timing = LOBBY_STAR_TIMINGS[starIndex % LOBBY_STAR_TIMINGS.length];
|
||||
const strengthTier =
|
||||
star.r >= 0.95 ? 'bright' : star.r >= 0.72 ? 'mid' : 'faint';
|
||||
const glintPeak =
|
||||
strengthTier === 'bright' ? 0.7 : strengthTier === 'mid' ? 0.61 : 0.52;
|
||||
const bloomPeak =
|
||||
strengthTier === 'bright' ? 0.16 : strengthTier === 'mid' ? 0.12 : 0.08;
|
||||
const coreLow =
|
||||
strengthTier === 'bright' ? 0.9 : strengthTier === 'mid' ? 0.73 : 0.6;
|
||||
const coreHigh =
|
||||
strengthTier === 'bright' ? 1 : strengthTier === 'mid' ? 0.9 : 0.82;
|
||||
const coreReduced =
|
||||
strengthTier === 'bright' ? 0.9 : strengthTier === 'mid' ? 0.76 : 0.66;
|
||||
|
||||
const coreStyle = {
|
||||
animationDuration: `${timing.duration}s`,
|
||||
animationDelay: `${timing.delay}s`,
|
||||
'--core-low': `${coreLow}`,
|
||||
'--core-high': `${coreHigh}`,
|
||||
'--core-reduced': `${coreReduced}`,
|
||||
} as CSSProperties;
|
||||
|
||||
const glintStyle = {
|
||||
animationDuration: `${timing.duration}s`,
|
||||
animationDelay: `${timing.delay + 0.12}s`,
|
||||
'--glint-peak': `${glintPeak}`,
|
||||
'--glint-base': '0.02',
|
||||
'--bloom-peak': `${bloomPeak}`,
|
||||
'--bloom-base': '0.01',
|
||||
} as CSSProperties;
|
||||
|
||||
const glintLength = star.r * 4.4 * (star.armScale ?? 1);
|
||||
const gradientXId = `glint-x-${starIndex}`;
|
||||
const gradientYId = `glint-y-${starIndex}`;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientXId}
|
||||
x1={star.cx - glintLength}
|
||||
y1={star.cy}
|
||||
x2={star.cx + glintLength}
|
||||
y2={star.cy}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={gradientYId}
|
||||
x1={star.cx}
|
||||
y1={star.cy - glintLength}
|
||||
x2={star.cx}
|
||||
y2={star.cy + glintLength}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx={star.cx} cy={star.cy} r={star.r} className="star-core" style={coreStyle} />
|
||||
<circle
|
||||
cx={star.cx}
|
||||
cy={star.cy}
|
||||
r={star.r * 1.9}
|
||||
className="star-core-bloom"
|
||||
style={coreStyle}
|
||||
/>
|
||||
|
||||
<line
|
||||
x1={star.cx - glintLength}
|
||||
y1={star.cy}
|
||||
x2={star.cx + glintLength}
|
||||
y2={star.cy}
|
||||
className="star-glint-bloom"
|
||||
stroke={`url(#${gradientXId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
<line
|
||||
x1={star.cx}
|
||||
y1={star.cy - glintLength}
|
||||
x2={star.cx}
|
||||
y2={star.cy + glintLength}
|
||||
className="star-glint-bloom"
|
||||
stroke={`url(#${gradientYId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
<line
|
||||
x1={star.cx - glintLength}
|
||||
y1={star.cy}
|
||||
x2={star.cx + glintLength}
|
||||
y2={star.cy}
|
||||
className="star-glint"
|
||||
stroke={`url(#${gradientXId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
<line
|
||||
x1={star.cx}
|
||||
y1={star.cy - glintLength}
|
||||
x2={star.cx}
|
||||
y2={star.cy + glintLength}
|
||||
className="star-glint"
|
||||
stroke={`url(#${gradientYId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user