diff --git a/frontend/src/features/liberation/pc/Destiny.jsx b/frontend/src/features/liberation/pc/Destiny.jsx new file mode 100644 index 0000000..faadfb8 --- /dev/null +++ b/frontend/src/features/liberation/pc/Destiny.jsx @@ -0,0 +1,54 @@ +import { DESTINY_CHAPTERS, DESTINY_QUEST_IMAGE_BASE } from '../data' +import { useLiberationStore } from '../store' +import ProgressBar from './components/ProgressBar' + +export default function Destiny() { + const calcMode = useLiberationStore((s) => s.destinyCalcMode) + const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode) + const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple) + + return ( + <> + {/* 계산 모드 탭 */} +
+ {[ + { key: 'simple', label: '단순 계산' }, + { key: 'weekly', label: '주차별 계산' }, + ].map((t) => { + const active = calcMode === t.key + return ( + + ) + })} +
+ + + + ) +} diff --git a/frontend/src/features/liberation/pc/Genesis.jsx b/frontend/src/features/liberation/pc/Genesis.jsx new file mode 100644 index 0000000..6bd3177 --- /dev/null +++ b/frontend/src/features/liberation/pc/Genesis.jsx @@ -0,0 +1,234 @@ +import { useState, useMemo } from 'react' +import dayjs from 'dayjs' +import { + GENESIS_CHAPTERS, + GENESIS_TOTAL, + MONTHLY_BOSSES, + QUEST_BOSS_IMAGE_BASE, + formatDate, +} from '../data' +import { useLiberationStore } from '../store' +import { + bossEarn, + calcWeekPoints, + calcDoneEarn, + calcMonthlyEarn, + getSchedulerWeekRange, + computeCompletionDate, +} from '../utils' +import QuestSelector from './components/QuestSelector' +import PointsInput from './components/PointsInput' +import ProgressBar from './components/ProgressBar' +import WeeklyDefault from './components/WeeklyDefault' +import DatePicker from '../../../components/common/DatePicker' +import ConfirmDialog from '../../../components/common/ConfirmDialog' + +export default function Genesis() { + const calcMode = useLiberationStore((s) => s.genesisCalcMode) + const state = useLiberationStore((s) => s[s.genesisCalcMode]) + const setCalcMode = useLiberationStore((s) => s.setGenesisCalcMode) + const updateSlot = useLiberationStore((s) => s.updateSlot) + const resetSlot = useLiberationStore((s) => s.resetSlot) + const setState = (updater) => updateSlot(updater) + + // 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감 + const priorConsumed = GENESIS_CHAPTERS + .slice(0, state.startChapter) + .reduce((s, c) => s + c.required, 0) + let cascadeIdx = state.startChapter + let cascadeRemain = state.currentPoints + let cascadeConsumed = 0 + while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) { + cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required + cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required + cascadeIdx++ + } + const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain + const alreadyDone = initialAccumulated >= GENESIS_TOTAL + const weeklyEarn = calcWeekPoints(state.weekly) + const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0) + const doneEarn = calcDoneEarn(state.weekly) + const monthlyEarn = calcMonthlyEarn(state.weekly) + const monthlyDoneThisMonth = !!state.weekly.blackMage?.done + + // 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트) + const headerWeekly = calcMode === 'weekly' + ? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0) + : weeklyEarn + const headerMonthly = (() => { + if (calcMode !== 'weekly') return monthlyEarn + const sw = state.schedulerWeeks || [] + if (!state.startDate) return 0 + const claimed = {} + sw.forEach((w, idx) => { + const diff = w.config.blackMage?.difficulty + if (!diff || diff === 'none') return + const r = getSchedulerWeekRange(state.startDate, idx + 1) + const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')] + for (const m of months) { + if (!(m in claimed)) { + claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage) + return + } + } + }) + return Object.values(claimed).reduce((s, v) => s + v, 0) + })() + + const completionDate = useMemo( + () => computeCompletionDate({ + calcMode, state, alreadyDone, remaining, + weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth, + }), + [calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth], + ) + const isDone = completionDate !== null + + const [resetOpen, setResetOpen] = useState(false) + const doReset = () => { + resetSlot() + setResetOpen(false) + } + + return ( + <> + {/* 계산 모드 탭 */} +
+ {[ + { key: 'simple', label: '단순 계산' }, + { key: 'weekly', label: '주차별 계산' }, + ].map((t) => { + const active = calcMode === t.key + return ( + + ) + })} +
+ + + + {/* 현재 진행 상태 입력 */} +
+
현재 진행 상태
+ +
+
+ + setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))} + /> +
+ +
+ + setState((prev) => ({ ...prev, startChapter: idx }))} + /> +
+ +
+ +
+ setState((prev) => ({ ...prev, currentPoints: n }))} + className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none" + style={{ color: 'var(--text-strong)' }} + /> + + / {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()} + +
+
+
+
+ + setState((prev) => ({ ...prev, weekly: w }))} + totalWeekly={headerWeekly} + totalMonthly={headerMonthly} + remaining={remaining} + mode={calcMode} + startDate={state.startDate} + weeks={state.schedulerWeeks} + onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))} + /> + +
+ +
+ + setResetOpen(false)} + onConfirm={doReset} + title="전체 초기화" + description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`} + confirmText="초기화" + destructive + /> + + ) +} diff --git a/frontend/src/features/liberation/pc/Liberation.jsx b/frontend/src/features/liberation/pc/Liberation.jsx index d696af3..552e234 100644 --- a/frontend/src/features/liberation/pc/Liberation.jsx +++ b/frontend/src/features/liberation/pc/Liberation.jsx @@ -1,29 +1,10 @@ -import { useState, useLayoutEffect, useMemo } from 'react' +import { useLayoutEffect } from 'react' import { useQuery } from '@tanstack/react-query' -import dayjs from 'dayjs' import { api } from '../../../api/client' -import { - GENESIS_CHAPTERS, - GENESIS_TOTAL, - MONTHLY_BOSSES, - formatDate, -} from '../data' -import { useLiberationStore } from '../store' -import { - bossEarn, - calcWeekPoints, - calcDoneEarn, - calcMonthlyEarn, - getSchedulerWeekRange, - computeCompletionDate, -} from '../utils' -import QuestSelector from './components/QuestSelector' -import PointsInput from './components/PointsInput' -import ProgressBar from './components/ProgressBar' -import WeeklyDefault from './components/WeeklyDefault' -import DatePicker from '../../../components/common/DatePicker' -import ConfirmDialog from '../../../components/common/ConfirmDialog' import { useLayout } from '../../../components/pc/Layout' +import { useLiberationStore } from '../store' +import Genesis from './Genesis' +import Destiny from './Destiny' export default function Liberation() { const { setFullscreen } = useLayout() @@ -32,7 +13,8 @@ export default function Liberation() { return () => setFullscreen(false) }, [setFullscreen]) - const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny' + const liberationType = useLiberationStore((s) => s.liberationType) + const setLiberationType = useLiberationStore((s) => s.setLiberationType) const genesisImg = useQuery({ queryKey: ['image', '제네시스 스태프'], @@ -45,72 +27,6 @@ export default function Liberation() { staleTime: Infinity, }) - const calcMode = useLiberationStore((s) => s.calcMode) - const state = useLiberationStore((s) => s[s.calcMode]) - const setCalcMode = useLiberationStore((s) => s.setCalcMode) - const updateSlot = useLiberationStore((s) => s.updateSlot) - const resetSlot = useLiberationStore((s) => s.resetSlot) - const setState = (updater) => updateSlot(updater) - - // 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감 - const priorConsumed = GENESIS_CHAPTERS - .slice(0, state.startChapter) - .reduce((s, c) => s + c.required, 0) - let cascadeIdx = state.startChapter - let cascadeRemain = state.currentPoints - let cascadeConsumed = 0 - while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) { - cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required - cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required - cascadeIdx++ - } - const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain - const alreadyDone = initialAccumulated >= GENESIS_TOTAL - const weeklyEarn = calcWeekPoints(state.weekly) - const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0) - const doneEarn = calcDoneEarn(state.weekly) - const monthlyEarn = calcMonthlyEarn(state.weekly) - const monthlyDoneThisMonth = !!state.weekly.blackMage?.done - - // 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트) - const headerWeekly = calcMode === 'weekly' - ? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0) - : weeklyEarn - const headerMonthly = (() => { - if (calcMode !== 'weekly') return monthlyEarn - const sw = state.schedulerWeeks || [] - if (!state.startDate) return 0 - const claimed = {} - sw.forEach((w, idx) => { - const diff = w.config.blackMage?.difficulty - if (!diff || diff === 'none') return - const r = getSchedulerWeekRange(state.startDate, idx + 1) - const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')] - for (const m of months) { - if (!(m in claimed)) { - claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage) - return - } - } - }) - return Object.values(claimed).reduce((s, v) => s + v, 0) - })() - - const completionDate = useMemo( - () => computeCompletionDate({ - calcMode, state, alreadyDone, remaining, - weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth, - }), - [calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth], - ) - const isDone = completionDate !== null - - const [resetOpen, setResetOpen] = useState(false) - const doReset = () => { - resetSlot() - setResetOpen(false) - } - return (
{/* 해방 종류 탭 */} @@ -144,156 +60,7 @@ export default function Liberation() { })}
- {liberationType === 'destiny' ? ( -
-
구현 예정
-
데스티니 해방 계산기는 준비 중입니다.
-
- ) : (<> - {/* 계산 모드 탭 */} -
- {[ - { key: 'simple', label: '단순 계산' }, - { key: 'weekly', label: '주차별 계산' }, - ].map((t) => { - const active = calcMode === t.key - return ( - - ) - })} -
- - - - {/* 현재 진행 상태 입력 */} -
-
현재 진행 상태
- -
-
- - setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))} - /> -
- -
- - setState((prev) => ({ ...prev, startChapter: idx }))} - /> -
- -
- -
- setState((prev) => ({ ...prev, currentPoints: n }))} - className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none" - style={{ color: 'var(--text-strong)' }} - /> - - / {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()} - -
-
-
-
- - setState((prev) => ({ ...prev, weekly: w }))} - totalWeekly={headerWeekly} - totalMonthly={headerMonthly} - remaining={remaining} - mode={calcMode} - startDate={state.startDate} - weeks={state.schedulerWeeks} - onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))} - /> - -
- -
- )} - - setResetOpen(false)} - onConfirm={doReset} - title="전체 초기화" - description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`} - confirmText="초기화" - destructive - /> + {liberationType === 'genesis' ? : } ) } diff --git a/frontend/src/features/liberation/pc/components/ProgressBar.jsx b/frontend/src/features/liberation/pc/components/ProgressBar.jsx index 801cec2..3685673 100644 --- a/frontend/src/features/liberation/pc/components/ProgressBar.jsx +++ b/frontend/src/features/liberation/pc/components/ProgressBar.jsx @@ -1,5 +1,3 @@ -import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data' - const DOW = ['일', '월', '화', '수', '목', '금', '토'] function formatKoreanDate(s) { const [y, m, d] = s.split('-') @@ -7,8 +5,15 @@ function formatKoreanDate(s) { return `${y}년 ${m}월 ${d}일 (${dow})` } -export default function ProgressBar({ startChapter, currentPoints, completionDate }) { - const chapterStates = GENESIS_CHAPTERS.map((c) => { +export default function ProgressBar({ + chapters, + imageBase, + startChapter, + currentPoints, + completionDate, + completionColor = 'var(--warning-text-bright)', +}) { + const chapterStates = chapters.map((c) => { if (c.idx < startChapter) return { chapter: c, status: 'done', current: c.required } if (c.idx === startChapter) { const filled = Math.min(currentPoints, c.required) @@ -41,7 +46,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat status === 'pending' ? 'opacity-50' : '' }`}> {chapter.boss} @@ -101,7 +106,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat · {completionDate ? formatKoreanDate(completionDate) : 미정} diff --git a/frontend/src/features/liberation/store.js b/frontend/src/features/liberation/store.js index b6f3d51..a64c6da 100644 --- a/frontend/src/features/liberation/store.js +++ b/frontend/src/features/liberation/store.js @@ -24,28 +24,55 @@ function makeInitialSlot() { } } +function makeInitialDestinySlot() { + return { + startChapter: 0, + currentPoints: 0, + startDate: dayjs(todayKST()).toISOString(), + } +} + /** * 해방 계산기 상태 - * calcMode: 'simple' | 'weekly' - * simple / weekly: 각 모드 독립 슬롯 + * calcMode: 'simple' | 'weekly' (제네시스/데스티니가 공유) + * simple / weekly: 제네시스 모드별 독립 슬롯 + * destinySimple / destinyWeekly: 데스티니 모드별 독립 슬롯 */ export const useLiberationStore = create(persist( (set) => ({ - calcMode: 'simple', + liberationType: 'genesis', // 'genesis' | 'destiny' + genesisCalcMode: 'simple', + destinyCalcMode: 'simple', simple: makeInitialSlot(), weekly: makeInitialSlot(), + destinySimple: makeInitialDestinySlot(), + destinyWeekly: makeInitialDestinySlot(), - setCalcMode: (mode) => set({ calcMode: mode }), + setLiberationType: (type) => set({ liberationType: type }), + setGenesisCalcMode: (mode) => set({ genesisCalcMode: mode }), + setDestinyCalcMode: (mode) => set({ destinyCalcMode: mode }), updateSlot: (patch) => set((s) => ({ - [s.calcMode]: typeof patch === 'function' - ? patch(s[s.calcMode]) - : { ...s[s.calcMode], ...patch }, + [s.genesisCalcMode]: typeof patch === 'function' + ? patch(s[s.genesisCalcMode]) + : { ...s[s.genesisCalcMode], ...patch }, })), - resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })), + resetSlot: () => set((s) => ({ [s.genesisCalcMode]: makeInitialSlot() })), + + updateDestinySlot: (patch) => set((s) => { + const key = s.destinyCalcMode === 'weekly' ? 'destinyWeekly' : 'destinySimple' + return { + [key]: typeof patch === 'function' ? patch(s[key]) : { ...s[key], ...patch }, + } + }), + + resetDestinySlot: () => set((s) => { + const key = s.destinyCalcMode === 'weekly' ? 'destinyWeekly' : 'destinySimple' + return { [key]: makeInitialDestinySlot() } + }), }), { name: 'maple-liberation' }, )) -export { makeEmptyWeekly, makeInitialSlot } +export { makeEmptyWeekly, makeInitialSlot, makeInitialDestinySlot } diff --git a/frontend/src/index.css b/frontend/src/index.css index 711f605..181b68c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -105,6 +105,8 @@ --warning-text-bright: #fcd34d; --warning-text-dim: rgba(252, 211, 77, 0.4); + --destiny-date: #38bdf8; + --progress-track: #0f172a; --progress-emerald: #10b981; --progress-amber: #f59e0b; @@ -256,6 +258,8 @@ --warning-text-bright: #ea580c; --warning-text-dim: rgba(234, 88, 12, 0.4); + --destiny-date: #0284c7; + --progress-track: #e5e7eb; --progress-emerald: #10b981; --progress-amber: #f59e0b;