데스티니 해방에 단순 계산 모드 주간 보스 설정 추가
WeeklyDefault/BossRow를 bosses/imageBase/hasScheduler prop 받도록 일반화. 데스티니 단순 계산 모드에서 8개 보스의 난이도·파티 인원·완료 상태를 설정할 수 있도록 구현. 주차별 계산은 플레이스홀더. store에 destiny weekly 슬롯 추가하고 v1 migrate로 기존 사용자의 localStorage backfill 처리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d506c022ca
commit
99500d91af
5 changed files with 101 additions and 18 deletions
|
|
@ -1,9 +1,17 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { DESTINY_CHAPTERS, DESTINY_QUEST_IMAGE_BASE, formatDate } from '../data'
|
import {
|
||||||
|
DESTINY_CHAPTERS,
|
||||||
|
DESTINY_QUEST_IMAGE_BASE,
|
||||||
|
DESTINY_BOSSES,
|
||||||
|
DESTINY_BOSS_IMAGE_BASE,
|
||||||
|
formatDate,
|
||||||
|
} from '../data'
|
||||||
import { useLiberationStore } from '../store'
|
import { useLiberationStore } from '../store'
|
||||||
|
import { calcWeekPoints } from '../utils'
|
||||||
import ProgressBar from './components/ProgressBar'
|
import ProgressBar from './components/ProgressBar'
|
||||||
import QuestSelector from './components/QuestSelector'
|
import QuestSelector from './components/QuestSelector'
|
||||||
import PointsInput from './components/PointsInput'
|
import PointsInput from './components/PointsInput'
|
||||||
|
import WeeklyDefault from './components/WeeklyDefault'
|
||||||
import DatePicker from '../../../components/common/DatePicker'
|
import DatePicker from '../../../components/common/DatePicker'
|
||||||
|
|
||||||
export default function Destiny() {
|
export default function Destiny() {
|
||||||
|
|
@ -13,6 +21,8 @@ export default function Destiny() {
|
||||||
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
|
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
|
||||||
const setState = (updater) => updateSlot(updater)
|
const setState = (updater) => updateSlot(updater)
|
||||||
|
|
||||||
|
const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 계산 모드 탭 */}
|
{/* 계산 모드 탭 */}
|
||||||
|
|
@ -115,6 +125,31 @@ export default function Destiny() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{calcMode === 'simple' ? (
|
||||||
|
<WeeklyDefault
|
||||||
|
bosses={DESTINY_BOSSES}
|
||||||
|
imageBase={DESTINY_BOSS_IMAGE_BASE}
|
||||||
|
weekly={state.weekly}
|
||||||
|
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||||
|
totalWeekly={weeklyEarn}
|
||||||
|
remaining={0}
|
||||||
|
mode="simple"
|
||||||
|
hasScheduler={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
boxShadow: 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xl font-bold" style={{ color: 'var(--text-emphasis)' }}>주차별 계산 준비 중</div>
|
||||||
|
<div className="text-sm mt-2" style={{ color: 'var(--text-dim)' }}>단순 계산 탭을 이용해주세요.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
GENESIS_CHAPTERS,
|
GENESIS_CHAPTERS,
|
||||||
GENESIS_TOTAL,
|
GENESIS_TOTAL,
|
||||||
|
WEEKLY_BOSSES,
|
||||||
MONTHLY_BOSSES,
|
MONTHLY_BOSSES,
|
||||||
QUEST_BOSS_IMAGE_BASE,
|
QUEST_BOSS_IMAGE_BASE,
|
||||||
|
LIBERATION_BOSS_IMAGE_BASE,
|
||||||
formatDate,
|
formatDate,
|
||||||
} from '../data'
|
} from '../data'
|
||||||
import { useLiberationStore } from '../store'
|
import { useLiberationStore } from '../store'
|
||||||
|
|
@ -193,6 +195,9 @@ export default function Genesis() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WeeklyDefault
|
<WeeklyDefault
|
||||||
|
bosses={WEEKLY_BOSSES}
|
||||||
|
monthlyBosses={MONTHLY_BOSSES}
|
||||||
|
imageBase={LIBERATION_BOSS_IMAGE_BASE}
|
||||||
weekly={state.weekly}
|
weekly={state.weekly}
|
||||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||||
totalWeekly={headerWeekly}
|
totalWeekly={headerWeekly}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Select from '../../../../components/common/Select'
|
import Select from '../../../../components/common/Select'
|
||||||
import Tooltip from '../../../../components/common/Tooltip'
|
import Tooltip from '../../../../components/common/Tooltip'
|
||||||
import WeeklyScheduler from './WeeklyScheduler'
|
import WeeklyScheduler from './WeeklyScheduler'
|
||||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../../data'
|
import { calcPoints } from '../../data'
|
||||||
|
|
||||||
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||||
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
||||||
|
|
@ -16,7 +16,7 @@ function diffLabel(d, party) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossRow({ boss, sel, onChange, monthly = false, showDone = true }) {
|
export function BossRow({ boss, sel, onChange, imageBase, monthly = false, showDone = true }) {
|
||||||
const disabled = sel.difficulty === 'none'
|
const disabled = sel.difficulty === 'none'
|
||||||
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
|
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
|
||||||
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
|
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
|
||||||
|
|
@ -24,7 +24,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
|
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
|
||||||
<Tooltip text={boss.name}>
|
<Tooltip text={boss.name}>
|
||||||
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
|
<img src={`${imageBase}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span className="text-base font-semibold flex-1 truncate">
|
<span className="text-base font-semibold flex-1 truncate">
|
||||||
{boss.name}
|
{boss.name}
|
||||||
|
|
@ -81,7 +81,22 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly, remaining, mode = 'simple', startDate, weeks, onChangeWeeks }) {
|
export default function WeeklyDefault({
|
||||||
|
bosses,
|
||||||
|
monthlyBosses = [],
|
||||||
|
imageBase,
|
||||||
|
weekly,
|
||||||
|
onChange,
|
||||||
|
totalWeekly,
|
||||||
|
totalMonthly = 0,
|
||||||
|
remaining,
|
||||||
|
mode = 'simple',
|
||||||
|
startDate,
|
||||||
|
weeks,
|
||||||
|
onChangeWeeks,
|
||||||
|
hasScheduler = true,
|
||||||
|
label = '주간 보스 설정',
|
||||||
|
}) {
|
||||||
const updateBoss = (key, patch) => {
|
const updateBoss = (key, patch) => {
|
||||||
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
|
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
|
||||||
}
|
}
|
||||||
|
|
@ -99,13 +114,17 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>주간 보스 설정</div>
|
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>{label}</div>
|
||||||
<div className="text-sm tabular-nums">
|
<div className="text-sm tabular-nums">
|
||||||
{mode === 'weekly' ? (
|
{mode === 'weekly' ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
|
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
|
||||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
|
{monthlyBosses.length > 0 && (
|
||||||
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
|
<>
|
||||||
|
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
|
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
|
||||||
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
|
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
|
||||||
</>
|
</>
|
||||||
|
|
@ -115,9 +134,9 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'simple' ? (
|
{mode === 'simple' || !hasScheduler ? (
|
||||||
<div>
|
<div>
|
||||||
{WEEKLY_BOSSES.map((boss, i) => (
|
{bosses.map((boss, i) => (
|
||||||
<div
|
<div
|
||||||
key={boss.key}
|
key={boss.key}
|
||||||
className={i > 0 ? 'border-t' : ''}
|
className={i > 0 ? 'border-t' : ''}
|
||||||
|
|
@ -127,10 +146,11 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
boss={boss}
|
boss={boss}
|
||||||
sel={weekly.bosses[boss.key]}
|
sel={weekly.bosses[boss.key]}
|
||||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||||
|
imageBase={imageBase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{MONTHLY_BOSSES.map((boss) => (
|
{monthlyBosses.map((boss) => (
|
||||||
<div
|
<div
|
||||||
key={boss.key}
|
key={boss.key}
|
||||||
className="border-t"
|
className="border-t"
|
||||||
|
|
@ -140,6 +160,7 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
boss={boss}
|
boss={boss}
|
||||||
sel={weekly.blackMage}
|
sel={weekly.blackMage}
|
||||||
onChange={updateBlackMage}
|
onChange={updateBlackMage}
|
||||||
|
imageBase={imageBase}
|
||||||
monthly
|
monthly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
|
import { WEEKLY_BOSSES, MONTHLY_BOSSES, DESTINY_BOSSES, todayKST } from './data'
|
||||||
|
|
||||||
function makeEmptyWeekly() {
|
function makeEmptyWeekly() {
|
||||||
const bosses = {}
|
const bosses = {}
|
||||||
|
|
@ -14,6 +14,14 @@ function makeEmptyWeekly() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeEmptyDestinyWeekly() {
|
||||||
|
const bosses = {}
|
||||||
|
DESTINY_BOSSES.forEach((b) => {
|
||||||
|
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
|
||||||
|
})
|
||||||
|
return { bosses }
|
||||||
|
}
|
||||||
|
|
||||||
function makeInitialSlot() {
|
function makeInitialSlot() {
|
||||||
return {
|
return {
|
||||||
startChapter: 0,
|
startChapter: 0,
|
||||||
|
|
@ -29,6 +37,7 @@ function makeInitialDestinySlot() {
|
||||||
startChapter: 0,
|
startChapter: 0,
|
||||||
currentPoints: 0,
|
currentPoints: 0,
|
||||||
startDate: dayjs(todayKST()).toISOString(),
|
startDate: dayjs(todayKST()).toISOString(),
|
||||||
|
weekly: makeEmptyDestinyWeekly(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +81,20 @@ export const useLiberationStore = create(persist(
|
||||||
return { [key]: makeInitialDestinySlot() }
|
return { [key]: makeInitialDestinySlot() }
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{ name: 'maple-liberation' },
|
{
|
||||||
|
name: 'maple-liberation',
|
||||||
|
version: 1,
|
||||||
|
migrate: (persisted) => {
|
||||||
|
if (!persisted) return persisted
|
||||||
|
// v0→v1: 데스티니 슬롯에 weekly 필드가 없으면 빈 값으로 채움
|
||||||
|
const fill = (slot) => slot ? { ...slot, weekly: slot.weekly || makeEmptyDestinyWeekly() } : slot
|
||||||
|
return {
|
||||||
|
...persisted,
|
||||||
|
destinySimple: fill(persisted.destinySimple),
|
||||||
|
destinyWeekly: fill(persisted.destinyWeekly),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
export { makeEmptyWeekly, makeInitialSlot, makeInitialDestinySlot }
|
export { makeEmptyWeekly, makeEmptyDestinyWeekly, makeInitialSlot, makeInitialDestinySlot }
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,17 @@ export function bossEarn(boss, sel) {
|
||||||
return calcPoints(d.points, sel.party)
|
return calcPoints(d.points, sel.party)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcWeekPoints(weekData) {
|
export function calcWeekPoints(weekData, bosses = WEEKLY_BOSSES) {
|
||||||
let points = 0
|
let points = 0
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
bosses.forEach((b) => {
|
||||||
points += bossEarn(b, weekData.bosses[b.key])
|
points += bossEarn(b, weekData.bosses[b.key])
|
||||||
})
|
})
|
||||||
return points
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcDoneEarn(weekData) {
|
export function calcDoneEarn(weekData, bosses = WEEKLY_BOSSES) {
|
||||||
let points = 0
|
let points = 0
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
bosses.forEach((b) => {
|
||||||
const sel = weekData.bosses[b.key]
|
const sel = weekData.bosses[b.key]
|
||||||
if (sel?.done) points += bossEarn(b, sel)
|
if (sel?.done) points += bossEarn(b, sel)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue