1077 lines
34 KiB
TypeScript
1077 lines
34 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { X, Lock } from "lucide-react";
|
||
import type { QuestNode } from "../types/quest";
|
||
|
||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||
const STYLES = `
|
||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||
|
||
/* ══ OVERLAY ══ */
|
||
.qnm-overlay {
|
||
position: fixed; inset: 0; z-index: 40;
|
||
background: rgba(4,8,20,0.72);
|
||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||
display: flex; align-items: flex-end; justify-content: center;
|
||
animation: qnmFade 0.22s ease both;
|
||
}
|
||
@keyframes qnmFade { from{opacity:0} to{opacity:1} }
|
||
|
||
/* ══ SHEET ══ */
|
||
.qnm-sheet {
|
||
width: 100%; max-width: 520px;
|
||
background: #06101f;
|
||
border-radius: 28px 28px 0 0;
|
||
border-top: 1.5px solid rgba(251,191,36,0.2);
|
||
box-shadow: 0 -12px 60px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06);
|
||
overflow: hidden;
|
||
display: flex; flex-direction: column;
|
||
max-height: 92vh;
|
||
animation: qnmUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both;
|
||
}
|
||
@keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} }
|
||
|
||
/* Handle */
|
||
.qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; }
|
||
.qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); }
|
||
|
||
/* Close btn */
|
||
.qnm-close {
|
||
position:absolute; top:0.9rem; right:1.1rem; z-index:10;
|
||
width:30px; height:30px; border-radius:50%;
|
||
border:1.5px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06);
|
||
display:flex; align-items:center; justify-content:center;
|
||
cursor:pointer; transition: all 0.15s ease;
|
||
}
|
||
.qnm-close:hover { border-color:rgba(251,191,36,0.5); background:rgba(251,191,36,0.1); }
|
||
|
||
/* ══ 3D ISLAND STAGE ══ */
|
||
.qnm-stage {
|
||
position: relative; flex-shrink: 0;
|
||
height: 200px; overflow: hidden;
|
||
background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%);
|
||
}
|
||
|
||
/* Sea waves */
|
||
.qnm-sea {
|
||
position:absolute; bottom:0; left:0; right:0; height:52px;
|
||
background: var(--sea-col); overflow:hidden;
|
||
}
|
||
.qnm-wave {
|
||
position:absolute; bottom:0; left:-100%; width:300%;
|
||
height:30px; border-radius:50% 50% 0 0;
|
||
background: rgba(255,255,255,0.07);
|
||
animation: qnmWave var(--wdur,5s) ease-in-out infinite;
|
||
}
|
||
.qnm-wave:nth-child(2){ animation-delay:-2s; opacity:0.5; }
|
||
.qnm-wave:nth-child(3){ animation-delay:-4s; opacity:0.3; height:20px; }
|
||
@keyframes qnmWave {
|
||
0% { transform: translateX(0) scaleY(1); }
|
||
50% { transform: translateX(15%) scaleY(1.08);}
|
||
100%{ transform: translateX(0) scaleY(1); }
|
||
}
|
||
|
||
/* Floating clouds */
|
||
.qnm-cloud {
|
||
position:absolute; border-radius:50px;
|
||
background: rgba(255,255,255,0.18);
|
||
filter: blur(4px);
|
||
animation: qnmDrift var(--cdur,18s) linear infinite;
|
||
}
|
||
@keyframes qnmDrift {
|
||
0% { transform: translateX(-120px); opacity:0; }
|
||
10% { opacity:1; }
|
||
90% { opacity:1; }
|
||
100%{ transform: translateX(calc(100vw + 120px)); opacity:0; }
|
||
}
|
||
|
||
/* ── The 3D island container ── */
|
||
.qnm-island-3d-wrap {
|
||
position: absolute;
|
||
left: 50%; bottom: 40px;
|
||
transform: translateX(-50%);
|
||
perspective: 420px;
|
||
width: 220px; height: 140px;
|
||
}
|
||
.qnm-island-3d {
|
||
width: 100%; height: 100%;
|
||
transform-style: preserve-3d;
|
||
animation: qnmIslandSpin 18s linear infinite;
|
||
position: relative;
|
||
}
|
||
@keyframes qnmIslandSpin {
|
||
0% { transform: rotateX(22deg) rotateY(0deg); }
|
||
100% { transform: rotateX(22deg) rotateY(360deg); }
|
||
}
|
||
|
||
/* Island layers — stacked in 3D */
|
||
.qnm-il { /* island layer base class */
|
||
position: absolute; left: 50%; bottom: 0;
|
||
transform-origin: bottom center;
|
||
border-radius: 50%;
|
||
transform-style: preserve-3d;
|
||
}
|
||
|
||
/* Water base disc */
|
||
.qnm-il-water {
|
||
width: 200px; height: 44px; margin-left: -100px;
|
||
background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col));
|
||
border-radius: 50%;
|
||
transform: translateZ(-4px);
|
||
box-shadow: 0 0 40px var(--sea-col);
|
||
animation: qnmWaterShimmer 3s ease-in-out infinite;
|
||
}
|
||
@keyframes qnmWaterShimmer {
|
||
0%,100%{ opacity:1; }
|
||
50% { opacity:0.82; }
|
||
}
|
||
|
||
/* Ripple rings on water */
|
||
.qnm-ripple {
|
||
position:absolute; left:50%; top:50%;
|
||
border-radius:50%; border:1.5px solid rgba(255,255,255,0.25);
|
||
animation: qnmRipple 2.8s ease-out infinite;
|
||
}
|
||
.qnm-ripple:nth-child(2){ animation-delay:-1.4s; }
|
||
@keyframes qnmRipple {
|
||
0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; }
|
||
100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; }
|
||
}
|
||
|
||
/* Island ground */
|
||
.qnm-il-ground {
|
||
width: 160px; height: 36px; margin-left: -80px;
|
||
background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo));
|
||
border-radius: 50%;
|
||
transform: translateZ(14px);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25);
|
||
}
|
||
|
||
/* Island side face — gives the 3D depth illusion */
|
||
.qnm-il-side {
|
||
width: 158px; height: 22px; margin-left: -79px;
|
||
bottom: -12px;
|
||
background: linear-gradient(180deg, var(--terr-lo), rgba(0,0,0,0.6));
|
||
clip-path: ellipse(79px 100% at 50% 0%);
|
||
transform: translateZ(8px) rotateX(-8deg);
|
||
}
|
||
|
||
/* Peak */
|
||
.qnm-il-peak {
|
||
width: 80px; height: 60px; margin-left: -40px;
|
||
bottom: 26px;
|
||
background: radial-gradient(ellipse at 42% 25%, var(--peak-hi), var(--peak-mid) 60%, var(--peak-lo));
|
||
clip-path: var(--peak-shape, polygon(50% 0%, 80% 55%, 100% 100%, 0% 100%, 20% 55%));
|
||
transform: translateZ(26px);
|
||
filter: drop-shadow(0 6px 12px rgba(0,0,0,0.5));
|
||
animation: qnmPeakBob 4s ease-in-out infinite;
|
||
}
|
||
@keyframes qnmPeakBob {
|
||
0%,100%{ transform: translateZ(26px) translateY(0); }
|
||
50% { transform: translateZ(26px) translateY(-4px); }
|
||
}
|
||
|
||
/* Floating decoration layer (trees, cactus, cloud orb, etc.) */
|
||
.qnm-il-deco {
|
||
position: absolute; bottom: 56px; left: 50%;
|
||
transform: translateZ(42px);
|
||
animation: qnmDecoFloat 3s ease-in-out infinite;
|
||
}
|
||
@keyframes qnmDecoFloat {
|
||
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
|
||
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
|
||
}
|
||
.qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); }
|
||
|
||
/* Flag pole on active */
|
||
.qnm-il-flag {
|
||
position:absolute; bottom:56px; left:50%;
|
||
transform: translateZ(50px) translateX(12px);
|
||
}
|
||
.qnm-flag-pole {
|
||
width:2px; height:26px; background:#7c4a1e;
|
||
border-radius:2px;
|
||
}
|
||
.qnm-flag-cloth {
|
||
position:absolute; top:2px; left:2px;
|
||
width:16px; height:11px;
|
||
background:#ef4444; clip-path:polygon(0%0%,100%25%,0%100%);
|
||
animation: qnmFlagWave 1.2s ease-in-out infinite;
|
||
transform-origin:left center;
|
||
}
|
||
@keyframes qnmFlagWave {
|
||
0%,100%{ transform:skewY(0deg); }
|
||
50% { transform:skewY(-10deg); }
|
||
}
|
||
|
||
/* Stars / sparkles above completed island */
|
||
.qnm-star {
|
||
position:absolute; font-size:1rem;
|
||
animation: qnmStarPop var(--sdur,2s) ease-in-out infinite;
|
||
animation-delay: var(--sdel,0s);
|
||
}
|
||
@keyframes qnmStarPop {
|
||
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
|
||
50% { transform:scale(1.4) translateY(-8px); opacity:1; }
|
||
}
|
||
|
||
/* ══ CONTENT BELOW THE STAGE ══ */
|
||
.qnm-body {
|
||
flex:1; overflow-y:auto; scrollbar-width:none;
|
||
display:flex; flex-direction:column; gap:0.85rem;
|
||
padding:1.1rem 1.25rem 0.5rem;
|
||
}
|
||
.qnm-body::-webkit-scrollbar { display:none; }
|
||
|
||
/* Title block */
|
||
.qnm-title-block { position:relative; }
|
||
.qnm-arc-tag {
|
||
display:inline-flex; align-items:center; gap:0.3rem;
|
||
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
||
text-transform:uppercase; color:var(--ac);
|
||
background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.08);
|
||
border-radius:100px; padding:0.18rem 0.6rem; margin-bottom:0.45rem;
|
||
}
|
||
.qnm-quest-title {
|
||
font-family:'Cinzel',serif;
|
||
font-size:1.22rem; font-weight:700; color:white;
|
||
letter-spacing:0.02em; line-height:1.2; margin-bottom:0.18rem;
|
||
}
|
||
.qnm-island-name {
|
||
font-family:'Nunito Sans',sans-serif;
|
||
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38);
|
||
}
|
||
|
||
/* Flavour quote */
|
||
.qnm-flavour {
|
||
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
||
border-left:3px solid var(--ac);
|
||
border-radius:0 14px 14px 0;
|
||
padding:0.8rem 1rem;
|
||
}
|
||
.qnm-flavour-text {
|
||
font-family:'Sorts Mill Goudy',serif;
|
||
font-size:0.82rem; color:rgba(255,255,255,0.55);
|
||
font-style:italic; line-height:1.6;
|
||
}
|
||
|
||
/* Objective card */
|
||
.qnm-obj-card {
|
||
background:rgba(255,255,255,0.04);
|
||
border:1px solid rgba(255,255,255,0.08);
|
||
border-radius:18px; padding:0.9rem 1rem;
|
||
}
|
||
.qnm-obj-header {
|
||
display:flex; align-items:center; justify-content:space-between; margin-bottom:0.65rem;
|
||
}
|
||
.qnm-obj-label {
|
||
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
||
text-transform:uppercase; color:rgba(255,255,255,0.3);
|
||
}
|
||
.qnm-obj-pct {
|
||
font-family:'Nunito',sans-serif;
|
||
font-size:0.78rem; font-weight:900; color:var(--ac);
|
||
}
|
||
.qnm-obj-row {
|
||
display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem;
|
||
}
|
||
.qnm-obj-icon {
|
||
width:38px; height:38px; border-radius:12px; flex-shrink:0;
|
||
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08);
|
||
display:flex; align-items:center; justify-content:center; font-size:1.1rem;
|
||
}
|
||
.qnm-obj-text {
|
||
font-family:'Nunito',sans-serif;
|
||
font-size:0.88rem; font-weight:900; color:white;
|
||
}
|
||
.qnm-obj-sub {
|
||
font-family:'Nunito Sans',sans-serif;
|
||
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem;
|
||
}
|
||
|
||
/* Progress bar */
|
||
.qnm-bar-track {
|
||
height:9px; background:rgba(255,255,255,0.07);
|
||
border-radius:100px; overflow:hidden; margin-bottom:0.3rem;
|
||
}
|
||
.qnm-bar-fill {
|
||
height:100%; border-radius:100px;
|
||
background:linear-gradient(90deg, var(--ac), color-mix(in srgb, var(--ac) 65%, white));
|
||
box-shadow:0 0 10px color-mix(in srgb, var(--ac) 55%, transparent);
|
||
transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);
|
||
}
|
||
.qnm-bar-nums {
|
||
display:flex; justify-content:space-between;
|
||
font-family:'Nunito',sans-serif;
|
||
font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28);
|
||
}
|
||
.qnm-bar-nums span:first-child { color:var(--ac); }
|
||
|
||
/* ── HOW TO COMPLETE section ── */
|
||
.qnm-howto-label {
|
||
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
||
text-transform:uppercase; color:rgba(255,255,255,0.3);
|
||
margin-bottom:0.55rem; margin-top:0.3rem;
|
||
}
|
||
.qnm-howto-badges {
|
||
display:flex; flex-wrap:wrap; gap:0.4rem;
|
||
}
|
||
.qnm-howto-badge {
|
||
display:flex; align-items:center; gap:0.3rem;
|
||
padding:0.38rem 0.75rem;
|
||
background:rgba(255,255,255,0.06);
|
||
border:1px solid rgba(255,255,255,0.1);
|
||
border-radius:100px;
|
||
font-family:'Nunito',sans-serif;
|
||
font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7);
|
||
transition: all 0.15s ease;
|
||
animation: qnmBadgeIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
|
||
animation-delay: var(--bdel, 0s);
|
||
}
|
||
@keyframes qnmBadgeIn {
|
||
from{ opacity:0; transform:scale(0.8) translateY(6px); }
|
||
to { opacity:1; transform:scale(1) translateY(0); }
|
||
}
|
||
.qnm-howto-badge:hover {
|
||
background:rgba(255,255,255,0.1);
|
||
border-color:rgba(255,255,255,0.2);
|
||
color:white;
|
||
transform:translateY(-1px);
|
||
}
|
||
/* Highlight badge = accent coloured */
|
||
.qnm-howto-badge.hi {
|
||
background:color-mix(in srgb, var(--ac) 18%, transparent);
|
||
border-color:color-mix(in srgb, var(--ac) 45%, transparent);
|
||
color:var(--ac);
|
||
}
|
||
|
||
/* Locked banner */
|
||
.qnm-locked-banner {
|
||
display:flex; align-items:center; gap:0.7rem;
|
||
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
||
border-radius:16px; padding:0.9rem 1rem;
|
||
}
|
||
.qnm-locked-icon {
|
||
width:38px; height:38px; border-radius:12px; flex-shrink:0;
|
||
background:rgba(255,255,255,0.05); display:flex; align-items:center; justify-content:center;
|
||
}
|
||
.qnm-locked-text {
|
||
font-family:'Nunito',sans-serif;
|
||
font-size:0.82rem; font-weight:800; color:rgba(255,255,255,0.4);
|
||
}
|
||
.qnm-locked-sub {
|
||
font-family:'Nunito Sans',sans-serif;
|
||
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem;
|
||
}
|
||
|
||
/* Reward card */
|
||
.qnm-reward-card {
|
||
background:rgba(251,191,36,0.07);
|
||
border:1px solid rgba(251,191,36,0.22);
|
||
border-radius:18px; padding:0.9rem 1rem;
|
||
}
|
||
.qnm-reward-label {
|
||
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
||
text-transform:uppercase; color:rgba(251,191,36,0.6); margin-bottom:0.6rem;
|
||
}
|
||
.qnm-reward-row { display:flex; flex-wrap:wrap; gap:0.4rem; }
|
||
.qnm-reward-pill {
|
||
display:flex; align-items:center; gap:0.28rem;
|
||
padding:0.35rem 0.8rem;
|
||
background:rgba(251,191,36,0.1); border:1.5px solid rgba(251,191,36,0.3);
|
||
border-radius:100px;
|
||
font-family:'Nunito',sans-serif;
|
||
font-size:0.75rem; font-weight:900; color:#fbbf24;
|
||
box-shadow:0 2px 8px rgba(251,191,36,0.1);
|
||
}
|
||
|
||
/* ══ FOOTER CTA ══ */
|
||
.qnm-footer {
|
||
padding:1rem 1.25rem calc(1rem + env(safe-area-inset-bottom));
|
||
flex-shrink:0; border-top:1px solid rgba(255,255,255,0.07);
|
||
background:#06101f;
|
||
}
|
||
.qnm-claim-btn {
|
||
width:100%; padding:0.95rem;
|
||
background:linear-gradient(135deg,#fbbf24,#f59e0b);
|
||
border:none; border-radius:16px; cursor:pointer;
|
||
font-family:'Cinzel',serif; font-size:1rem; font-weight:700; color:#1a0800;
|
||
letter-spacing:0.04em;
|
||
box-shadow:0 5px 0 #d97706, 0 8px 24px rgba(251,191,36,0.35);
|
||
transition:all 0.12s ease;
|
||
}
|
||
.qnm-claim-btn:hover { transform:translateY(-2px); box-shadow:0 7px 0 #d97706; }
|
||
.qnm-claim-btn:active { transform:translateY(2px); box-shadow:0 3px 0 #d97706; }
|
||
.qnm-note {
|
||
text-align:center;
|
||
font-family:'Nunito Sans',sans-serif;
|
||
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.28);
|
||
padding:0.4rem 0;
|
||
}
|
||
`;
|
||
|
||
// ─── Per-arc terrain themes ───────────────────────────────────────────────────
|
||
interface Terrain {
|
||
skyTop: string;
|
||
skyBot: string;
|
||
seaCol: string;
|
||
seaHi: string;
|
||
terrHi: string;
|
||
terrMid: string;
|
||
terrLo: string;
|
||
peakHi: string;
|
||
peakMid: string;
|
||
peakLo: string;
|
||
decos: string[];
|
||
}
|
||
|
||
const TERRAIN: Record<string, Terrain> = {
|
||
east_blue: {
|
||
skyTop: "#0a1628",
|
||
skyBot: "#0d2240",
|
||
seaCol: "#0a3d5c",
|
||
seaHi: "#1a6a8a",
|
||
terrHi: "#5eead4",
|
||
terrMid: "#0d9488",
|
||
terrLo: "#0f5c55",
|
||
peakHi: "#a7f3d0",
|
||
peakMid: "#34d399",
|
||
peakLo: "#065f46",
|
||
decos: ["🌴", "🌿"],
|
||
},
|
||
alabasta: {
|
||
skyTop: "#1c0a00",
|
||
skyBot: "#3d1a00",
|
||
seaCol: "#7c3a00",
|
||
seaHi: "#c26010",
|
||
terrHi: "#fde68a",
|
||
terrMid: "#d97706",
|
||
terrLo: "#78350f",
|
||
peakHi: "#fef3c7",
|
||
peakMid: "#fbbf24",
|
||
peakLo: "#92400e",
|
||
decos: ["🌵", "🏺"],
|
||
},
|
||
skypiea: {
|
||
skyTop: "#1a0033",
|
||
skyBot: "#2e0050",
|
||
seaCol: "#4c1d95",
|
||
seaHi: "#7c3aed",
|
||
terrHi: "#e9d5ff",
|
||
terrMid: "#a855f7",
|
||
terrLo: "#581c87",
|
||
peakHi: "#f5d0fe",
|
||
peakMid: "#d946ef",
|
||
peakLo: "#701a75",
|
||
decos: ["☁️", "✨"],
|
||
},
|
||
};
|
||
const DEFAULT_TERRAIN = TERRAIN.east_blue;
|
||
|
||
// ─── Per-requirement how-to badges ───────────────────────────────────────────
|
||
interface Badge {
|
||
emoji: string;
|
||
label: string;
|
||
highlight?: boolean;
|
||
}
|
||
|
||
const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
|
||
questions: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "📝", label: "Take a Practice Sheet", highlight: true },
|
||
{ emoji: "⚡", label: "Try Drills Mode" },
|
||
{ emoji: "🎯", label: "Aim for 10+ per session" },
|
||
{ emoji: "🔄", label: "Retry completed sheets" },
|
||
],
|
||
},
|
||
accuracy: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "🐢", label: "Slow down, read carefully", highlight: true },
|
||
{ emoji: "📖", label: "Review wrong answers" },
|
||
{ emoji: "🎯", label: "Do targeted practice", highlight: true },
|
||
{ emoji: "💡", label: "Use process of elimination" },
|
||
],
|
||
},
|
||
streak: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "📅", label: "Practice every day", highlight: true },
|
||
{ emoji: "⏰", label: "Set a daily reminder" },
|
||
{ emoji: "🔥", label: "Even 5 mins counts" },
|
||
{ emoji: "🛡️", label: "Use a Streak Shield" },
|
||
],
|
||
},
|
||
sessions: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "📝", label: "Start a Practice Sheet", highlight: true },
|
||
{ emoji: "⚡", label: "Complete Drills" },
|
||
{ emoji: "🏃", label: "Short sessions count too" },
|
||
{ emoji: "📚", label: "Try different modules" },
|
||
],
|
||
},
|
||
topics: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "🗺️", label: "Explore new modules", highlight: true },
|
||
{ emoji: "📊", label: "Check your weak topics" },
|
||
{ emoji: "🔍", label: "Use Search to find topics" },
|
||
{ emoji: "⚡", label: "Drill each topic once" },
|
||
],
|
||
},
|
||
xp: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "🎯", label: "High accuracy = bonus XP", highlight: true },
|
||
{ emoji: "🔥", label: "Maintain your streak" },
|
||
{ emoji: "📝", label: "Complete full sheets", highlight: true },
|
||
{ emoji: "⚡", label: "Use XP Boosts" },
|
||
],
|
||
},
|
||
leaderboard: {
|
||
title: "How to complete this",
|
||
badges: [
|
||
{ emoji: "📈", label: "Aim for 80%+ accuracy", highlight: true },
|
||
{ emoji: "🔥", label: "Keep your streak alive" },
|
||
{ emoji: "📝", label: "Do sessions daily" },
|
||
{ emoji: "🏆", label: "Check the leaderboard" },
|
||
],
|
||
},
|
||
};
|
||
|
||
// ─── Island shape configs (mirrors the 6 clip-path shapes in QuestMap) ────────
|
||
// groundClip = clip-path for the flat top disc of the island
|
||
// peakClip = clip-path for the hill/feature rising above it
|
||
// groundW/H = pixel size of the ground layer
|
||
// peakW/H = pixel size of the peak layer
|
||
// sideClip = clip-path for the side-face depth layer
|
||
interface ShapeConfig {
|
||
groundClip: string;
|
||
peakClip: string;
|
||
sideClip: string;
|
||
groundW: number;
|
||
groundH: number;
|
||
peakW: number;
|
||
peakH: number;
|
||
peakBottom: number; // translateZ bottom offset in px
|
||
}
|
||
|
||
// These correspond 1-to-1 with SHAPES[0..5] in QuestMap.tsx
|
||
const ISLAND_SHAPES: ShapeConfig[] = [
|
||
// 0: fat round atoll
|
||
{
|
||
groundClip: "ellipse(50% 50% at 50% 50%)",
|
||
peakClip: "ellipse(50% 50% at 50% 55%)",
|
||
sideClip: "ellipse(50% 100% at 50% 0%)",
|
||
groundW: 160,
|
||
groundH: 38,
|
||
peakW: 88,
|
||
peakH: 38,
|
||
peakBottom: 26,
|
||
},
|
||
// 1: tall mountain — narrow diamond ground, sharp triangular peak
|
||
{
|
||
groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)",
|
||
peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)",
|
||
sideClip: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
|
||
groundW: 148,
|
||
groundH: 36,
|
||
peakW: 72,
|
||
peakH: 72,
|
||
peakBottom: 24,
|
||
},
|
||
// 2: wide flat shoal — extra-wide squashed ellipse, low dome
|
||
{
|
||
groundClip: "ellipse(50% 40% at 50% 58%)",
|
||
peakClip: "ellipse(50% 38% at 50% 60%)",
|
||
sideClip: "ellipse(50% 100% at 50% 0%)",
|
||
groundW: 178,
|
||
groundH: 30,
|
||
peakW: 114,
|
||
peakH: 28,
|
||
peakBottom: 22,
|
||
},
|
||
// 3: jagged rocky reef — star-burst polygon
|
||
{
|
||
groundClip:
|
||
"polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)",
|
||
peakClip:
|
||
"polygon(50% 0%, 63% 32%, 98% 32%, 71% 54%, 80% 90%, 50% 70%, 20% 90%, 29% 54%, 2% 32%, 37% 32%)",
|
||
sideClip: "ellipse(50% 100% at 50% 0%)",
|
||
groundW: 152,
|
||
groundH: 38,
|
||
peakW: 80,
|
||
peakH: 66,
|
||
peakBottom: 24,
|
||
},
|
||
// 4: crescent — lopsided asymmetric bean
|
||
{
|
||
groundClip:
|
||
"path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')",
|
||
peakClip: "ellipse(38% 55% at 38% 50%)",
|
||
sideClip: "ellipse(50% 100% at 50% 0%)",
|
||
groundW: 160,
|
||
groundH: 36,
|
||
peakW: 80,
|
||
peakH: 58,
|
||
peakBottom: 22,
|
||
},
|
||
// 5: teardrop/pear — narrow top, wide rounded base
|
||
{
|
||
groundClip:
|
||
"path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')",
|
||
peakClip:
|
||
"polygon(50% 0%, 73% 27%, 88% 62%, 68% 98%, 32% 98%, 12% 62%, 27% 27%)",
|
||
sideClip: "ellipse(50% 100% at 50% 0%)",
|
||
groundW: 144,
|
||
groundH: 38,
|
||
peakW: 76,
|
||
peakH: 66,
|
||
peakBottom: 24,
|
||
},
|
||
];
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
const reqIcon = (type: string): string =>
|
||
({
|
||
questions: "❓",
|
||
accuracy: "🎯",
|
||
streak: "🔥",
|
||
sessions: "📚",
|
||
topics: "🗺️",
|
||
xp: "⚡",
|
||
leaderboard: "🏆",
|
||
})[type] ?? "⭐";
|
||
|
||
// ─── 3D Island Stage ──────────────────────────────────────────────────────────
|
||
const IslandStage = ({
|
||
arcId,
|
||
status,
|
||
nodeIndex,
|
||
}: {
|
||
arcId: string;
|
||
status: QuestNode["status"];
|
||
nodeIndex: number;
|
||
}) => {
|
||
const t = TERRAIN[arcId] ?? DEFAULT_TERRAIN;
|
||
const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length];
|
||
|
||
const isCompleted = status === "completed";
|
||
const isClaimable = status === "claimable";
|
||
const isActive = status === "active";
|
||
const isLocked = status === "locked";
|
||
|
||
const vars = {
|
||
"--sky-top": t.skyTop,
|
||
"--sky-bot": t.skyBot,
|
||
"--sea-col": t.seaCol,
|
||
"--sea-hi": t.seaHi,
|
||
"--terr-hi": t.terrHi,
|
||
"--terr-mid": t.terrMid,
|
||
"--terr-lo": t.terrLo,
|
||
"--peak-hi": t.peakHi,
|
||
"--peak-mid": t.peakMid,
|
||
"--peak-lo": t.peakLo,
|
||
} as React.CSSProperties;
|
||
|
||
return (
|
||
<div className="qnm-stage" style={vars}>
|
||
{/* Clouds */}
|
||
{[
|
||
{ w: 70, h: 22, top: 14, delay: 0, dur: 16 },
|
||
{ w: 50, h: 16, top: 28, delay: -7, dur: 22 },
|
||
{ w: 40, h: 14, top: 10, delay: -3, dur: 28 },
|
||
].map((c, i) => (
|
||
<div
|
||
key={i}
|
||
className="qnm-cloud"
|
||
style={
|
||
{
|
||
width: c.w,
|
||
height: c.h,
|
||
top: c.top,
|
||
"--cdur": `${c.dur}s`,
|
||
animationDelay: `${c.delay}s`,
|
||
opacity: isLocked ? 0.08 : 0.18,
|
||
} as React.CSSProperties
|
||
}
|
||
/>
|
||
))}
|
||
|
||
{/* Sea + waves */}
|
||
<div className="qnm-sea">
|
||
<div
|
||
className="qnm-wave"
|
||
style={{ "--wdur": "5s" } as React.CSSProperties}
|
||
/>
|
||
<div
|
||
className="qnm-wave"
|
||
style={{ "--wdur": "7s" } as React.CSSProperties}
|
||
/>
|
||
<div
|
||
className="qnm-wave"
|
||
style={{ "--wdur": "9s" } as React.CSSProperties}
|
||
/>
|
||
</div>
|
||
|
||
{/* Ripple rings on water surface */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: "50%",
|
||
bottom: 32,
|
||
transform: "translateX(-50%)",
|
||
width: 0,
|
||
height: 0,
|
||
}}
|
||
>
|
||
<div className="qnm-ripple" />
|
||
<div className="qnm-ripple" />
|
||
</div>
|
||
|
||
{/* 3D island */}
|
||
<div
|
||
className="qnm-island-3d-wrap"
|
||
style={{ opacity: isLocked ? 0.3 : 1 }}
|
||
>
|
||
<div
|
||
className="qnm-island-3d"
|
||
style={{
|
||
// Pause rotation when locked
|
||
animationPlayState: isLocked ? "paused" : "running",
|
||
}}
|
||
>
|
||
{/* Water base */}
|
||
<div className="qnm-il qnm-il-water" />
|
||
|
||
{/* Island side face */}
|
||
<div
|
||
className="qnm-il qnm-il-side"
|
||
style={{
|
||
width: shp.groundW,
|
||
marginLeft: -(shp.groundW / 2),
|
||
clipPath: shp.sideClip,
|
||
}}
|
||
/>
|
||
|
||
{/* Island ground — shaped to match QuestMap */}
|
||
<div
|
||
className="qnm-il qnm-il-ground"
|
||
style={{
|
||
width: shp.groundW,
|
||
height: shp.groundH,
|
||
marginLeft: -(shp.groundW / 2),
|
||
clipPath: shp.groundClip,
|
||
borderRadius: 0,
|
||
}}
|
||
/>
|
||
|
||
{/* Peak / hill — shaped to match QuestMap */}
|
||
{!isLocked && (
|
||
<div
|
||
className="qnm-il qnm-il-peak"
|
||
style={{
|
||
width: shp.peakW,
|
||
height: shp.peakH,
|
||
marginLeft: -(shp.peakW / 2),
|
||
bottom: shp.peakBottom,
|
||
clipPath: shp.peakClip,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Decorations */}
|
||
{!isLocked &&
|
||
t.decos.map((deco, di) => (
|
||
<div
|
||
key={di}
|
||
className="qnm-il-deco"
|
||
style={{
|
||
marginLeft: di === 0 ? "-30px" : "8px",
|
||
animationDelay: `${di * 0.6}s`,
|
||
}}
|
||
>
|
||
<span className="qnm-deco-emoji">{deco}</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Pirate flag on active */}
|
||
{isActive && (
|
||
<div className="qnm-il-flag">
|
||
<div className="qnm-flag-pole" />
|
||
<div className="qnm-flag-cloth" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Chest bouncing on claimable */}
|
||
{isClaimable && (
|
||
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
|
||
<span
|
||
className="qnm-deco-emoji"
|
||
style={{
|
||
animation: "qnmPeakBob 1s ease-in-out infinite",
|
||
fontSize: "1.6rem",
|
||
}}
|
||
>
|
||
📦
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Lock icon on locked */}
|
||
{isLocked && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: "50%",
|
||
bottom: 50,
|
||
transform: "translateX(-50%) translateZ(30px)",
|
||
fontSize: "2rem",
|
||
opacity: 0.5,
|
||
}}
|
||
>
|
||
🔒
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sparkles for completed */}
|
||
{isCompleted && (
|
||
<>
|
||
{[
|
||
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
|
||
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
|
||
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
|
||
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
|
||
].map((s, i) => (
|
||
<span
|
||
key={i}
|
||
className="qnm-star"
|
||
style={
|
||
{
|
||
left: s.left,
|
||
top: s.top,
|
||
"--sdur": s.sdur,
|
||
"--sdel": s.sdel,
|
||
} as React.CSSProperties
|
||
}
|
||
>
|
||
✨
|
||
</span>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* Lock overlay tint */}
|
||
{isLocked && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
background: "rgba(0,0,0,0.45)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: "2.5rem", opacity: 0.6 }}>🔒</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── Main component ───────────────────────────────────────────────────────────
|
||
interface Props {
|
||
node: QuestNode;
|
||
arcAccent: string;
|
||
arcDark: string;
|
||
arcId?: string;
|
||
nodeIndex?: number;
|
||
onClose: () => void;
|
||
onClaim: () => void;
|
||
}
|
||
|
||
export const QuestNodeModal = ({
|
||
node,
|
||
arcAccent,
|
||
arcDark,
|
||
arcId = "east_blue",
|
||
nodeIndex = 0,
|
||
onClose,
|
||
onClaim,
|
||
}: Props) => {
|
||
const [mounted, setMounted] = useState(false);
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
const progress = Math.min(
|
||
100,
|
||
Math.round((node.progress / node.requirement.target) * 100),
|
||
);
|
||
const isClaimable = node.status === "claimable";
|
||
const isLocked = node.status === "locked";
|
||
const isCompleted = node.status === "completed";
|
||
const isActive = node.status === "active";
|
||
const howTo = HOW_TO[node.requirement.type];
|
||
|
||
return (
|
||
<div
|
||
className="qnm-overlay"
|
||
onClick={onClose}
|
||
style={{ "--ac": arcAccent } as React.CSSProperties}
|
||
>
|
||
<style>{STYLES}</style>
|
||
<div className="qnm-sheet" onClick={(e) => e.stopPropagation()}>
|
||
{/* Handle + close */}
|
||
<div className="qnm-handle-row">
|
||
<div className="qnm-handle" />
|
||
</div>
|
||
<button className="qnm-close" onClick={onClose}>
|
||
<X size={13} color="rgba(255,255,255,0.5)" />
|
||
</button>
|
||
|
||
{/* 3D island stage */}
|
||
<IslandStage arcId={arcId} status={node.status} nodeIndex={nodeIndex} />
|
||
|
||
{/* Scrollable content */}
|
||
<div className="qnm-body">
|
||
{/* Title */}
|
||
<div className="qnm-title-block">
|
||
<div className="qnm-arc-tag">
|
||
{reqIcon(node.requirement.type)} Quest
|
||
</div>
|
||
<h2 className="qnm-quest-title">{node.title}</h2>
|
||
<p className="qnm-island-name">📍 {node.islandName}</p>
|
||
</div>
|
||
|
||
{/* Flavour */}
|
||
<div className="qnm-flavour">
|
||
<p className="qnm-flavour-text">{node.flavourText}</p>
|
||
</div>
|
||
|
||
{/* Objective */}
|
||
<div className="qnm-obj-card">
|
||
<div className="qnm-obj-header">
|
||
<span className="qnm-obj-label">⚓ Objective</span>
|
||
{!isLocked && (
|
||
<span className="qnm-obj-pct">
|
||
{isCompleted ? "✅ Done" : `${progress}%`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="qnm-obj-row">
|
||
<div className="qnm-obj-icon">
|
||
{reqIcon(node.requirement.type)}
|
||
</div>
|
||
<div>
|
||
<p className="qnm-obj-text">
|
||
{node.requirement.target} {node.requirement.label}
|
||
</p>
|
||
<p className="qnm-obj-sub">
|
||
{isCompleted
|
||
? "✅ Completed — treasure claimed!"
|
||
: isLocked
|
||
? "🔒 Complete previous quests first"
|
||
: `${node.progress} / ${node.requirement.target} done`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress bar */}
|
||
{!isLocked && (
|
||
<>
|
||
<div className="qnm-bar-track">
|
||
<div
|
||
className="qnm-bar-fill"
|
||
style={{ width: mounted ? `${progress}%` : "0%" }}
|
||
/>
|
||
</div>
|
||
<div className="qnm-bar-nums">
|
||
<span>{node.progress}</span>
|
||
<span>{node.requirement.target}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* How-to badges — show when active or claimable */}
|
||
{(isActive || isClaimable) && howTo && (
|
||
<>
|
||
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
|
||
🧭 {howTo.title}
|
||
</p>
|
||
<div className="qnm-howto-badges">
|
||
{howTo.badges.map((b, bi) => (
|
||
<div
|
||
key={bi}
|
||
className={`qnm-howto-badge${b.highlight ? " hi" : ""}`}
|
||
style={
|
||
{ "--bdel": `${bi * 0.06}s` } as React.CSSProperties
|
||
}
|
||
>
|
||
<span>{b.emoji}</span>
|
||
<span>{b.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Locked banner */}
|
||
{isLocked && (
|
||
<div className="qnm-locked-banner">
|
||
<div className="qnm-locked-icon">
|
||
<Lock size={18} color="rgba(255,255,255,0.3)" />
|
||
</div>
|
||
<div>
|
||
<p className="qnm-locked-text">Quest Locked</p>
|
||
<p className="qnm-locked-sub">
|
||
Complete the previous island to sail here
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reward */}
|
||
<div className="qnm-reward-card">
|
||
<p className="qnm-reward-label">📦 Treasure Chest</p>
|
||
<div className="qnm-reward-row">
|
||
<div className="qnm-reward-pill">⚡ +{node.reward.xp} XP</div>
|
||
{node.reward.title && (
|
||
<div className="qnm-reward-pill">🏴☠️ {node.reward.title}</div>
|
||
)}
|
||
{node.reward.itemLabel && (
|
||
<div className="qnm-reward-pill">
|
||
🎁 {node.reward.itemLabel}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer CTA */}
|
||
<div className="qnm-footer">
|
||
{isClaimable ? (
|
||
<button className="qnm-claim-btn" onClick={onClaim}>
|
||
⚓ Open Your Treasure Chest
|
||
</button>
|
||
) : isCompleted ? (
|
||
<p className="qnm-note">✅ Completed — treasure claimed!</p>
|
||
) : isLocked ? (
|
||
<p className="qnm-note">🔒 Locked — keep sailing</p>
|
||
) : (
|
||
<p className="qnm-note">
|
||
{progress}% complete · {node.requirement.target - node.progress}{" "}
|
||
{node.requirement.label} remaining
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|