feat(treasure): add treasure quest, quest modal, island node, quest widget
This commit is contained in:
996
src/pages/student/QuestMap.tsx
Normal file
996
src/pages/student/QuestMap.tsx
Normal 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]}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user