feat(treasure): add treasure quest, quest modal, island node, quest widget

This commit is contained in:
shafin-r
2026-02-26 01:31:48 +06:00
parent 894863c196
commit f64d2cac4a
12 changed files with 4018 additions and 19 deletions

View File

@ -0,0 +1,996 @@
import { useState, useRef } from "react";
import { Lock, CheckCircle } from "lucide-react";
import type { QuestArc, QuestNode, NodeStatus } from "../../types/quest";
import { useQuestStore, getQuestSummary } from "../../stores/useQuestStore";
import { QuestNodeModal } from "../../components/QuestNodeModal";
import { ChestOpenModal } from "../../components/ChestOpenModal";
// ─── Map geometry (all in SVG user-units, viewBox width = 390) ───────────────
const VW = 390; // viewBox width — matches typical phone width
const ROW_GAP = 260; // vertical distance between island centres
const TOP_PAD = 80; // y of first island centre
// Three column x-centres: Left=22%, Centre=50%, Right=78%
const COL_X = [
Math.round(VW * 0.22), // 86
Math.round(VW * 0.5), // 195
Math.round(VW * 0.78), // 304
];
// Per-arc column sequences — each arc winds differently across the map.
// 0 = Left (22%), 1 = Centre (50%), 2 = Right (78%)
const ARC_COL_SEQS: Record<string, number[]> = {
east_blue: [0, 1, 2, 0, 1, 2], // steady L→C→R march
alabasta: [2, 0, 2, 1, 0, 2], // sharp zigzag, heavy right bias
skypiea: [1, 2, 0, 2, 0, 1], // wide sweeping swings C→R→L→R→L→C
};
const COL_SEQ_DEFAULT = [0, 1, 2, 0, 1, 2];
// Card half-width / half-height for the foreign-object card
const CARD_W = 130;
const CARD_H = 195;
const islandCX = (i: number, arcId: string) => {
const seq = ARC_COL_SEQS[arcId] ?? COL_SEQ_DEFAULT;
return COL_X[seq[i % seq.length]];
};
const islandCY = (i: number) => TOP_PAD + i * ROW_GAP;
// Total SVG height
const svgHeight = (n: number) => TOP_PAD + (n - 1) * ROW_GAP + TOP_PAD + CARD_H;
// ─── Island shapes (clip-path on a 110×65 rect centred at 0,0) ───────────────
const SHAPES = [
// 0: fat round atoll
`<ellipse cx="0" cy="0" rx="57" ry="33"/>`,
// 1: tall mountain peak
`<polygon points="0,-38 28,-14 48,10 40,33 22,38 -22,38 -40,33 -48,10 -28,-14"/>`,
// 2: wide flat shoal
`<ellipse cx="0" cy="5" rx="62" ry="26"/>`,
// 3: jagged rocky reef
`<polygon points="0,-38 20,-14 50,-8 32,12 42,36 16,24 0,38 -16,24 -42,36 -32,12 -50,-8 -20,-14"/>`,
// 4: crescent (right side bites in)
`<path d="M-50,0 C-50,-34 -20,-38 0,-36 C22,-34 48,-18 50,4 C52,24 36,30 18,24 C6,20 4,10 10,4 C16,-4 26,-4 28,4 C30,12 22,18 12,16 C-4,10 -10,-8 0,-20 C12,-32 -30,-28 -50,0 Z"/>`,
// 5: teardrop/pear
`<path d="M0,-38 C18,-38 44,-18 44,8 C44,28 26,38 0,38 C-26,38 -44,28 -44,8 C-44,-18 -18,-38 0,-38 Z"/>`,
];
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
* { box-sizing: border-box; }
.qm-screen {
height: 100vh; font-family: 'Nunito', sans-serif;
position: relative; display: flex; flex-direction: column;
overflow: hidden; background: #060e1f;
}
/* ══ HEADER ══ */
.qm-header {
position: relative; z-index: 30; flex-shrink: 0;
background: rgba(4,10,24,0.94);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(251,191,36,0.12);
padding: 1.25rem 1.25rem 0;
}
.qm-page-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 1.3rem; font-weight: 900; letter-spacing: 0.05em;
color: #fbbf24;
text-shadow: 0 0 24px rgba(251,191,36,0.5), 0 0 60px rgba(251,191,36,0.15);
margin-bottom: 0.15rem;
}
.qm-page-sub {
font-family: 'Nunito Sans', sans-serif; font-size: 0.7rem; font-weight: 600;
color: rgba(255,255,255,0.35); margin-bottom: 0.85rem;
}
.qm-stats-strip {
display: flex; gap: 0.4rem; overflow-x: auto;
scrollbar-width: none; padding-bottom: 0.85rem;
}
.qm-stats-strip::-webkit-scrollbar { display:none; }
.qm-stat-chip {
display: flex; align-items: center; gap: 0.3rem;
padding: 0.28rem 0.7rem; flex-shrink: 0;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
border-radius: 100px;
}
.qm-stat-val { font-size:0.76rem; font-weight:900; color:#fbbf24; }
.qm-stat-label { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:600; color:rgba(255,255,255,0.35); }
.qm-arc-tabs {
display: flex; gap:0; overflow-x:auto; scrollbar-width:none;
border-top: 1px solid rgba(255,255,255,0.06);
}
.qm-arc-tabs::-webkit-scrollbar { display:none; }
.qm-arc-tab {
flex-shrink:0; display:flex; align-items:center; gap:0.4rem;
padding: 0.6rem 1rem; border:none; background:transparent; cursor:pointer;
font-family:'Nunito',sans-serif; font-weight:800; font-size:0.78rem;
color: rgba(255,255,255,0.3); border-bottom: 3px solid transparent;
transition: all 0.2s ease; white-space:nowrap;
}
.qm-arc-tab:hover { color:rgba(255,255,255,0.6); }
.qm-arc-tab.active { color:var(--arc-accent); border-bottom-color:var(--arc-accent); }
.qm-tab-dot {
width:7px; height:7px; border-radius:50%;
background:#ef4444; box-shadow:0 0 8px #ef4444;
animation: qmDotBlink 1.4s ease-in-out infinite;
}
@keyframes qmDotBlink { 0%,100%{ opacity:1; } 50%{ opacity:0.4; } }
/* ══ SEA ══ */
.qm-sea-scroll {
flex:1; overflow-y:auto; overflow-x:hidden;
position:relative; scrollbar-width:none; -webkit-overflow-scrolling:touch;
}
.qm-sea-scroll::-webkit-scrollbar { display:none; }
.qm-sea {
position:relative; min-height:100%;
padding: 1.25rem 1.25rem 8rem;
background:
radial-gradient(ellipse 80% 40% at 20% 15%, rgba(6,80,160,0.45) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 60%, rgba(4,50,110,0.35) 0%, transparent 55%),
radial-gradient(ellipse 70% 40% at 50% 90%, rgba(8,120,180,0.2) 0%, transparent 50%),
linear-gradient(180deg, #071530 0%, #04101e 40%, #020a14 100%);
overflow:hidden;
}
.qm-sea-shimmer {
position:absolute; inset:0; pointer-events:none; z-index:0;
background:
repeating-linear-gradient(105deg, transparent 0%, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
repeating-linear-gradient(75deg, transparent 0%, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
background-size: 400% 400%, 300% 300%;
animation: qmSeaMove 14s ease-in-out infinite alternate;
}
@keyframes qmSeaMove {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
.qm-bubble {
position:absolute; border-radius:50%; pointer-events:none; z-index:1;
background: rgba(255,255,255,0.045);
animation: qmBobble var(--bdur) ease-in-out infinite;
animation-delay: var(--bdelay);
}
@keyframes qmBobble {
0%,100%{ transform:translateY(0) scale(1); opacity:0.5; }
50% { transform:translateY(-10px) scale(1.1); opacity:0.9; }
}
/* ── Arc banner ── */
.qm-arc-banner {
position:relative; z-index:5;
border-radius:22px; padding: 1.1rem 1.25rem; margin-bottom: 1.5rem;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 12px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
overflow:hidden;
}
.qm-arc-banner::before {
content:''; position:absolute; inset:0;
background: repeating-linear-gradient(45deg, transparent, transparent 15px, rgba(255,255,255,0.015) 15px, rgba(255,255,255,0.015) 16px);
}
.qm-arc-banner-bg-emoji {
position:absolute; right:0.5rem; top:50%; transform:translateY(-50%);
font-size:5rem; opacity:0.09; filter:blur(2px); pointer-events:none; z-index:0;
}
.qm-arc-banner-name {
font-family:'Sorts Mill Goudy',serif; font-size:1.25rem; font-weight:900; color:white;
letter-spacing:0.06em; text-shadow: 0 2px 16px rgba(0,0,0,0.6); position:relative; z-index:1;
}
.qm-arc-banner-sub {
font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600;
color:rgba(255,255,255,0.5); margin-top:0.2rem; position:relative; z-index:1;
}
.qm-arc-banner-prog {
display:flex; align-items:center; gap:0.65rem; margin-top:0.8rem; position:relative; z-index:1;
}
.qm-arc-banner-track { flex:1; height:5px; border-radius:100px; background:rgba(255,255,255,0.12); overflow:hidden; }
.qm-arc-banner-fill {
height:100%; border-radius:100px; background:rgba(255,255,255,0.8);
box-shadow:0 0 8px rgba(255,255,255,0.5); transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);
}
.qm-arc-banner-count {
font-family:'Nunito',sans-serif; font-size:0.68rem; font-weight:900;
color:rgba(255,255,255,0.65); white-space:nowrap;
}
/* ══ MAP SVG CANVAS ══ */
.qm-map-svg { display:block; width:100%; overflow:visible; position:relative; z-index:5; }
/* ── Info card (foreignObject inside SVG) ── */
.qm-info-card {
background: rgba(255,255,255,0.055); border:1px solid rgba(255,255,255,0.09);
border-radius:16px; padding:0.7rem 0.85rem;
backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
transition: background 0.15s ease, border-color 0.15s ease;
overflow:hidden;
}
.qm-info-card.is-claimable { border-color:rgba(251,191,36,0.45); background:rgba(251,191,36,0.07); }
.qm-info-card.is-active { border-color:rgba(255,255,255,0.14); }
.qm-info-card.is-locked { opacity:0.42; }
.qm-info-row1 { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.4rem; gap:0.4rem; }
.qm-info-title { font-family:'Sorts Mill Goudy',serif; font-size:0.78rem; font-weight:700; color:white; line-height:1.25; }
.qm-info-loc { font-size:0.52rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:var(--arc-accent); margin-bottom:0.12rem; }
.qm-xp-badge {
display:flex; align-items:center; gap:0.18rem; padding:0.18rem 0.45rem;
background:rgba(251,191,36,0.13); border:1px solid rgba(251,191,36,0.3);
border-radius:100px; flex-shrink:0;
}
.qm-xp-badge-val { font-size:0.62rem; font-weight:900; color:#fbbf24; }
.qm-prog-track { height:5px; background:rgba(255,255,255,0.08); border-radius:100px; overflow:hidden; margin-bottom:0.22rem; }
.qm-prog-fill {
height:100%; border-radius:100px;
background:linear-gradient(90deg, var(--arc-accent), color-mix(in srgb,var(--arc-accent) 65%,white));
box-shadow:0 0 8px color-mix(in srgb,var(--arc-accent) 55%,transparent);
transition:width 0.7s cubic-bezier(0.34,1.56,0.64,1);
}
.qm-prog-label { font-family:'Nunito Sans',sans-serif; font-size:0.55rem; font-weight:700; color:rgba(255,255,255,0.38); }
.qm-claim-btn {
width:100%; margin-top:0.5rem; padding:0.48rem;
background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:10px; cursor:pointer;
font-family:'Sorts Mill Goudy',serif; font-size:0.72rem; font-weight:700;
color:#1a0e00; letter-spacing:0.04em;
box-shadow:0 3px 0 #d97706, 0 5px 14px rgba(251,191,36,0.3); transition:all 0.12s ease;
}
.qm-claim-btn:hover { transform:translateY(-1px); box-shadow:0 5px 0 #d97706; }
.qm-claim-btn:active { transform:translateY(1px); box-shadow:0 1px 0 #d97706; }
/* ══ ARC COMPLETE ══ */
.qm-arc-done {
position:relative; z-index:5; margin-top:1.5rem; padding:1.25rem; text-align:center;
background:linear-gradient(135deg,rgba(251,191,36,0.12),rgba(251,191,36,0.04));
border:1px solid rgba(251,191,36,0.3); border-radius:20px;
box-shadow:0 0 40px rgba(251,191,36,0.06);
}
.qm-arc-done-title {
font-family:'Sorts Mill Goudy',serif; font-size:1rem; font-weight:900; color:#fbbf24;
text-shadow:0 0 20px rgba(251,191,36,0.6); margin-bottom:0.2rem;
}
.qm-arc-done-sub { font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600; color:rgba(251,191,36,0.55); }
/* ══ FAB ══ */
.qm-fab {
position:fixed; bottom:calc(1.25rem + 80px + env(safe-area-inset-bottom)); right:1.25rem; z-index:25;
width:52px; height:52px; border-radius:50%;
background:linear-gradient(135deg,#1a0e45,#3730a3); border:2px solid rgba(251,191,36,0.45);
display:flex; align-items:center; justify-content:center; font-size:1.5rem; cursor:pointer;
box-shadow:0 6px 24px rgba(0,0,0,0.55), 0 0 0 1px rgba(251,191,36,0.15);
animation:qmFabFloat 4s ease-in-out infinite;
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
}
.qm-fab:hover { transform:scale(1.1) rotate(8deg); }
.qm-fab:active { transform:scale(0.92); }
@keyframes qmFabFloat { 0%,100%{ transform:translateY(0) rotate(-4deg); } 50%{ transform:translateY(-7px) rotate(4deg); } }
/* ══ NODE ENTRANCE ══ */
@keyframes qmIslandIn { from{ opacity:0; transform:scale(0.82) translateY(22px); } to{ opacity:1; transform:scale(1) translateY(0); } }
.qm-island-in { animation: qmIslandIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both; }
`;
// ─── Data ─────────────────────────────────────────────────────────────────────
const TERRAIN: Record<string, { l: string; m: string; d: string; s: string }> =
{
east_blue: {
l: "#5eead4",
m: "#0d9488",
d: "#0f766e",
s: "rgba(13,148,136,0.55)",
},
alabasta: {
l: "#fcd34d",
m: "#d97706",
d: "#92400e",
s: "rgba(146,64,14,0.65)",
},
skypiea: {
l: "#d8b4fe",
m: "#9333ea",
d: "#6b21a8",
s: "rgba(107,33,168,0.55)",
},
};
const DECOS: Record<string, [string, string, string]> = {
east_blue: ["🌴", "🌿", "🌴"],
alabasta: ["🌵", "🏺", "🌵"],
skypiea: ["☁️", "✨", "☁️"],
};
const REQ_ICON: Record<string, string> = {
questions: "❓",
accuracy: "🎯",
streak: "🔥",
sessions: "📚",
topics: "🗺️",
xp: "⚡",
leaderboard: "🏆",
};
const FOAM = Array.from({ length: 22 }, (_, i) => ({
id: i,
w: 10 + ((i * 17 + 7) % 24),
top: `${3 + ((i * 13) % 88)}%`,
left: `${(i * 19 + 5) % 96}%`,
dur: `${4 + ((i * 7) % 7)}s`,
delay: `${(i * 3) % 5}s`,
}));
const completedCount = (arc: QuestArc) =>
arc.nodes.filter((n) => n.status === "completed").length;
// ─── SVG Island node ──────────────────────────────────────────────────────────
const IslandNode = ({
node,
arcId,
accent,
index,
cx,
cy,
onTap,
onClaim,
}: {
node: QuestNode;
arcId: string;
accent: string;
index: number;
cx: number;
cy: number;
onTap: (n: QuestNode) => void;
onClaim: (n: QuestNode) => void;
}) => {
const terrain = TERRAIN[arcId] ?? TERRAIN.east_blue;
const decos = DECOS[arcId] ?? DECOS.east_blue;
const isCompleted = node.status === "completed";
const isClaimable = node.status === "claimable";
const isActive = node.status === "active";
const isLocked = node.status === "locked";
const pct = Math.min(
100,
Math.round((node.progress / node.requirement.target) * 100),
);
const hiC = isLocked ? "#4b5563" : isCompleted ? "#6ee7b7" : terrain.l;
const midC = isLocked ? "#374151" : isCompleted ? "#10b981" : terrain.m;
const loC = isLocked ? "#1f2937" : isCompleted ? "#065f46" : terrain.d;
const shdC = isLocked ? "rgba(0,0,0,0.5)" : terrain.s;
const gradId = `grad-${node.id}`;
const clipId = `clip-${node.id}`;
const shadowId = `shadow-${node.id}`;
const glowId = `glow-${node.id}`;
const shapeIdx = index % SHAPES.length;
const LAND_H = 38;
const cardTop = cy + LAND_H + 18;
const statusCard = isClaimable
? "is-claimable"
: isActive
? "is-active"
: isLocked
? "is-locked"
: "is-completed";
return (
<g
style={{ cursor: isLocked ? "default" : "pointer" }}
onClick={() => !isLocked && onTap(node)}
>
<defs>
<radialGradient id={gradId} cx="38%" cy="28%" r="65%">
<stop offset="0%" stopColor={hiC} />
<stop offset="55%" stopColor={midC} />
<stop offset="100%" stopColor={loC} />
</radialGradient>
<filter id={shadowId} x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow
dx="0"
dy="9"
stdDeviation="7"
floodColor={shdC}
floodOpacity="0.8"
/>
</filter>
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="7" result="blur" />
<feFlood
floodColor={isClaimable ? "#fbbf24" : accent}
floodOpacity="0.55"
result="col"
/>
<feComposite in="col" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<clipPath id={clipId}>
<g dangerouslySetInnerHTML={{ __html: SHAPES[shapeIdx] }} />
</clipPath>
</defs>
{/* Water shimmer halo */}
<ellipse
cx={cx}
cy={cy + LAND_H - 4}
rx={isLocked ? 40 : 56}
ry={12}
fill="rgba(56,189,248,0.22)"
style={{ filter: "blur(5px)" }}
>
<animate
attributeName="rx"
values={`${isLocked ? 40 : 56};${isLocked ? 46 : 62};${isLocked ? 40 : 56}`}
dur="3s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.6;1;0.6"
dur="3s"
repeatCount="indefinite"
/>
</ellipse>
{/* Land shadow blob */}
<g
transform={`translate(${cx},${cy + 12})`}
style={{ filter: "blur(10px)" }}
opacity="0.5"
>
<g
dangerouslySetInnerHTML={{ __html: SHAPES[shapeIdx] }}
style={{ fill: shdC }}
/>
</g>
{/* Active / claimable glow ring */}
{(isActive || isClaimable) && (
<g transform={`translate(${cx},${cy}) scale(1.22)`}>
<g
dangerouslySetInnerHTML={{
__html: SHAPES[shapeIdx]
.replace(
">",
` fill="none" stroke="${isClaimable ? "#fbbf24" : accent}" stroke-width="1.8" stroke-dasharray="6 4" opacity="0.6">`,
)
.replace("<", "<"),
}}
/>
</g>
)}
{/* Land shape */}
<g
transform={`translate(${cx},${cy})`}
filter={`url(#${isActive || isClaimable ? glowId : shadowId})`}
opacity={isLocked ? 0.45 : 1}
>
<g
dangerouslySetInnerHTML={{
__html: SHAPES[shapeIdx].replace(">", ` fill="url(#${gradId})">`),
}}
/>
</g>
{/* Decorations */}
{!isLocked && (
<>
<text
x={cx - 22}
y={cy - LAND_H - 6}
fontSize="13"
textAnchor="middle"
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.5))" }}
>
{decos[0]}
</text>
<text
x={cx + 22}
y={cy - LAND_H - 2}
fontSize="15"
textAnchor="middle"
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.5))" }}
>
{decos[1]}
</text>
</>
)}
{/* Pirate flag on active */}
{isActive && (
<g transform={`translate(${cx - 8},${cy - LAND_H - 26})`}>
<line
x1="0"
y1="0"
x2="0"
y2="-20"
stroke="#6b4226"
strokeWidth="2"
strokeLinecap="round"
/>
<path d="M0,-20 L16,-14 L0,-8Z" fill="#ef4444" />
</g>
)}
{/* Bouncing chest on claimable */}
{isClaimable && (
<text
x={cx}
y={cy - LAND_H - 8}
fontSize="18"
textAnchor="middle"
style={{ filter: "drop-shadow(0 4px 8px rgba(251,191,36,0.7))" }}
>
📦
<animate
attributeName="y"
values={`${cy - LAND_H - 8};${cy - LAND_H - 18};${cy - LAND_H - 8}`}
dur="1.4s"
repeatCount="indefinite"
/>
</text>
)}
{/* Lock icon */}
{isLocked && (
<text
x={cx}
y={cy + 6}
fontSize="18"
textAnchor="middle"
dominantBaseline="middle"
opacity="0.4"
>
🔒
</text>
)}
{/* Quest emoji */}
{!isLocked && (
<text
x={cx}
y={cy + 6}
fontSize="18"
textAnchor="middle"
dominantBaseline="middle"
style={{ filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.5))" }}
>
{node.emoji}
</text>
)}
{/* Completed check */}
{isCompleted && (
<g transform={`translate(${cx + 40},${cy - LAND_H + 4})`}>
<circle
r="11"
fill="#22c55e"
stroke="rgba(255,255,255,0.9)"
strokeWidth="2.2"
/>
<text x="0" y="5" fontSize="12" textAnchor="middle" fill="white">
</text>
</g>
)}
{/* Island name label */}
<text
x={cx}
y={cy + LAND_H + 10}
fontSize="8.5"
fontFamily="'Sorts Mill Goudy',serif"
fontWeight="700"
fill="rgba(255,255,255,0.45)"
textAnchor="middle"
letterSpacing="0.1em"
>
{node.islandName?.toUpperCase()}
</text>
{/* Info card via foreignObject */}
<foreignObject
x={cx - CARD_W / 2}
y={cardTop}
width={CARD_W}
height={CARD_H}
style={{ overflow: "visible" }}
onClick={(e) => e.stopPropagation()}
>
<div
className={`qm-info-card ${statusCard}`}
style={{ ["--arc-accent" as string]: accent }}
onClick={() => !isLocked && onTap(node)}
>
<div className="qm-info-row1">
<p className="qm-info-title">{node.title}</p>
<div className="qm-xp-badge">
<span style={{ fontSize: "0.58rem" }}></span>
<span className="qm-xp-badge-val">+{node.reward.xp}</span>
</div>
</div>
{(isActive || isClaimable) && (
<>
<div className="qm-prog-track">
<div className="qm-prog-fill" style={{ width: `${pct}%` }} />
</div>
<p className="qm-prog-label">
{REQ_ICON[node.requirement.type]}&nbsp;
{node.progress}/{node.requirement.target}{" "}
{node.requirement.label}
</p>
</>
)}
{isLocked && (
<p className="qm-prog-label">
🔒 {node.requirement.target} {node.requirement.label} to unlock
</p>
)}
{isCompleted && (
<p className="qm-prog-label" style={{ color: "#4ade80" }}>
Conquered!
</p>
)}
{isClaimable && (
<button
className="qm-claim-btn"
onClick={(e) => {
e.stopPropagation();
onClaim(node);
}}
>
Open Chest
</button>
)}
</div>
</foreignObject>
</g>
);
};
// ─── SVG Path between two island centres ─────────────────────────────────────
const RoutePath = ({
x1,
y1,
x2,
y2,
done,
accent,
showShip,
}: {
x1: number;
y1: number;
x2: number;
y2: number;
done: boolean;
accent: string;
showShip: boolean;
}) => {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const perp = 55;
const side = x1 < x2 ? 1 : -1;
const cpx = mx - (dy / len) * perp * side;
const cpy = my + (dx / len) * perp * side;
const path = `M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`;
const shipX = 0.25 * x1 + 0.5 * cpx + 0.25 * x2;
const shipY = 0.25 * y1 + 0.5 * cpy + 0.25 * y2;
return (
<g>
<path
d={path}
fill="none"
stroke="rgba(255,255,255,0.06)"
strokeWidth="18"
strokeLinecap="round"
/>
<path
d={path}
fill="none"
stroke={done ? accent : "rgba(255,255,255,0.2)"}
strokeWidth={done ? "2.5" : "1.8"}
strokeDasharray={done ? "10 6" : "6 8"}
strokeLinecap="round"
style={{ filter: done ? `drop-shadow(0 0 5px ${accent})` : "none" }}
/>
{[0.25, 0.5, 0.75].map((t, ti) => {
const ex = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cpx + t * t * x2;
const ey = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cpy + t * t * y2;
return (
<ellipse
key={ti}
cx={ex}
cy={ey}
rx="8"
ry="2.5"
fill="rgba(255,255,255,0.04)"
/>
);
})}
{showShip && (
<text
x={shipX}
y={shipY}
fontSize="18"
textAnchor="middle"
dominantBaseline="middle"
style={{ filter: "drop-shadow(0 3px 6px rgba(0,0,0,0.5))" }}
>
<animateTransform
attributeName="transform"
type="translate"
values="0,0;0,-5;0,0"
dur="2.5s"
additive="sum"
repeatCount="indefinite"
/>
<animateTransform
attributeName="transform"
type="rotate"
values={`-6,${shipX},${shipY};6,${shipX},${shipY};-6,${shipX},${shipY}`}
dur="2.5s"
additive="sum"
repeatCount="indefinite"
/>
</text>
)}
</g>
);
};
// ─── Main ─────────────────────────────────────────────────────────────────────
export const QuestMap = () => {
// ── Store — select ONLY stable primitives/actions, never derived functions ──
const arcs = useQuestStore((s) => s.arcs);
const activeArcId = useQuestStore((s) => s.activeArcId);
const setActiveArc = useQuestStore((s) => s.setActiveArc);
const claimNode = useQuestStore((s) => s.claimNode);
// Derived values — computed from arcs outside the selector, never causes loops
const summary = getQuestSummary(arcs);
// ── Local UI state (doesn't need to be global) ──
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
const done = completedCount(arc);
const pct = Math.round((done / arc.nodes.length) * 100);
const handleClaim = (node: QuestNode) => setClaimingNode(node);
const handleChestClose = () => {
if (!claimingNode) return;
claimNode(arc.id, claimingNode.id); // store handles state update + next unlock
setClaimingNode(null);
};
const nodes = arc.nodes;
const centres = nodes.map((_, i) => ({
x: islandCX(i, arc.id),
y: islandCY(i),
}));
const totalSvgH = svgHeight(nodes.length);
return (
<div className="qm-screen">
<style>{STYLES}</style>
{/* Header */}
<div className="qm-header">
<p className="qm-page-title">🏴 Treasure Quests</p>
<p className="qm-page-sub">Chart your course across the Grand Line</p>
<div className="qm-stats-strip">
{[
{
e: "⚓",
v: `${summary.completedNodes}/${summary.totalNodes}`,
l: "Quests",
},
{ e: "⚡", v: `${summary.earnedXP} XP`, l: "Earned" },
{ e: "📦", v: `${summary.claimableNodes}`, l: "Chests" },
{
e: "🏝️",
v: `${summary.arcsCompleted}/${summary.totalArcs}`,
l: "Arcs",
},
].map((s) => (
<div key={s.l} className="qm-stat-chip">
<span style={{ fontSize: "0.78rem" }}>{s.e}</span>
<span className="qm-stat-val">{s.v}</span>
<span className="qm-stat-label">{s.l}</span>
</div>
))}
</div>
<div className="qm-arc-tabs">
{arcs.map((a) => (
<button
key={a.id}
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
style={{ "--arc-accent": a.accentColor } as React.CSSProperties}
onClick={() => {
setActiveArc(a.id);
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
>
{a.emoji} {a.name}
{a.nodes.some((n) => n.status === "claimable") && (
<span className="qm-tab-dot" />
)}
</button>
))}
</div>
</div>
{/* Sea */}
<div className="qm-sea-scroll" ref={scrollRef}>
<div className="qm-sea">
<div className="qm-sea-shimmer" />
{FOAM.map((b) => (
<div
key={b.id}
className="qm-bubble"
style={
{
width: b.w,
height: b.w,
top: b.top,
left: b.left,
"--bdur": b.dur,
"--bdelay": b.delay,
} as React.CSSProperties
}
/>
))}
{/* Arc banner */}
<div
className="qm-arc-banner"
style={{
background: `linear-gradient(135deg,${arc.bgFrom}dd,${arc.bgTo}ee)`,
}}
>
<div className="qm-arc-banner-bg-emoji">{arc.emoji}</div>
<p className="qm-arc-banner-name">{arc.name}</p>
<p className="qm-arc-banner-sub">{arc.subtitle}</p>
<div className="qm-arc-banner-prog">
<div className="qm-arc-banner-track">
<div
className="qm-arc-banner-fill"
style={{ width: `${pct}%` }}
/>
</div>
<span className="qm-arc-banner-count">
{done}/{arc.nodes.length} islands
</span>
</div>
</div>
{/* ── Single SVG canvas for the whole map ── */}
<svg
className="qm-map-svg"
viewBox={`0 0 ${VW} ${totalSvgH}`}
height={totalSvgH}
preserveAspectRatio="xMidYMin meet"
>
{/* Routes drawn FIRST (behind islands) */}
{nodes.map((node, i) => {
if (i >= nodes.length - 1) return null;
const c1 = centres[i];
const c2 = centres[i + 1];
const ship =
node.status === "completed" &&
nodes[i + 1]?.status === "active";
return (
<RoutePath
key={`route-${i}`}
x1={c1.x}
y1={c1.y}
x2={c2.x}
y2={c2.y}
done={node.status === "completed"}
accent={arc.accentColor}
showShip={ship}
/>
);
})}
{/* Islands drawn on top */}
{nodes.map((node, i) => (
<IslandNode
key={node.id}
node={node}
arcId={arc.id}
accent={arc.accentColor}
index={i}
cx={centres[i].x}
cy={centres[i].y}
onTap={setSelectedNode}
onClaim={handleClaim}
/>
))}
{/* Arc complete seal */}
{done === nodes.length && (
<g transform={`translate(${VW / 2},${totalSvgH - 60})`}>
<circle
r="42"
fill="rgba(251,191,36,0.12)"
stroke="rgba(251,191,36,0.5)"
strokeWidth="1.5"
strokeDasharray="8 4"
/>
<circle
r="34"
fill="rgba(255,248,200,0.9)"
stroke="rgba(180,120,20,0.4)"
strokeWidth="1.5"
/>
<text
x="0"
y="-8"
fontFamily="'Cinzel',serif"
fontSize="8"
fontWeight="900"
fill="#92400e"
textAnchor="middle"
letterSpacing="0.12em"
>
ARC
</text>
<text
x="0"
y="5"
fontFamily="'Cinzel',serif"
fontSize="8"
fontWeight="900"
fill="#92400e"
textAnchor="middle"
letterSpacing="0.12em"
>
COMPLETE
</text>
<text x="0" y="19" fontSize="13" textAnchor="middle">
</text>
</g>
)}
</svg>
</div>
</div>
<div
className="qm-fab"
onClick={() =>
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })
}
>
🏴
</div>
{selectedNode && (
<QuestNodeModal
node={selectedNode}
arcAccent={arc.accentColor}
arcDark={arc.accentDark}
arcId={arc.id}
nodeIndex={arc.nodes.findIndex((n) => n.id === selectedNode.id)}
onClose={() => setSelectedNode(null)}
onClaim={() => {
setSelectedNode(null);
handleClaim(selectedNode);
}}
/>
)}
{claimingNode && (
<ChestOpenModal node={claimingNode} onClose={handleChestClose} />
)}
</div>
);
};