2219 lines
83 KiB
TypeScript
2219 lines
83 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||
import type {
|
||
QuestArc,
|
||
QuestNode,
|
||
ClaimedRewardResponse,
|
||
} from "../../types/quest";
|
||
import { useQuestStore } from "../../stores/useQuestStore";
|
||
import { useAuthStore } from "../../stores/authStore";
|
||
import { api } from "../../utils/api";
|
||
import { QuestNodeModal } from "../../components/QuestNodeModal";
|
||
import { ChestOpenModal } from "../../components/ChestOpenModal";
|
||
import { InfoHeader } from "../../components/InfoHeader";
|
||
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
||
import { Island3D } from "../../components/Island3D";
|
||
import { Billboard, OrbitControls, Stars, Text } from "@react-three/drei";
|
||
import * as THREE from "three";
|
||
|
||
// ─── Map geometry ─────────────────────────────────────────────────────────────
|
||
const VW = 420;
|
||
const TOP_PAD = 80;
|
||
const ROW_H = 520; // vertical step per island (increased for more separation)
|
||
|
||
// ─── Seeded RNG ───────────────────────────────────────────────────────────────
|
||
const mkRng = (seed: number) => {
|
||
let s = seed >>> 0;
|
||
return () => {
|
||
s += 0x6d2b79f5;
|
||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
};
|
||
|
||
const strToSeed = (str: string) => {
|
||
let h = 5381;
|
||
for (let i = 0; i < str.length; i++)
|
||
h = (Math.imul(h, 33) ^ str.charCodeAt(i)) >>> 0;
|
||
return h;
|
||
};
|
||
|
||
// ─── Random island positions ──────────────────────────────────────────────────
|
||
// Generates organic-feeling positions that zigzag downward with random offsets.
|
||
// Uses a seeded RNG per arc so positions are deterministic per arc but varied.
|
||
const MIN_DIST = 600; // minimum px distance between any two islands (increased for more separation)
|
||
|
||
const generateIslandPositions = (
|
||
nodeCount: number,
|
||
arcId: string,
|
||
): { x: number; y: number }[] => {
|
||
const rng = mkRng(strToSeed(arcId + "_positions"));
|
||
|
||
// X zones: left, centre-left, centre, centre-right, right
|
||
const ZONES = [
|
||
[0.12, 0.32],
|
||
[0.28, 0.48],
|
||
[0.38, 0.62],
|
||
[0.52, 0.72],
|
||
[0.68, 0.88],
|
||
];
|
||
|
||
const positions: { x: number; y: number }[] = [];
|
||
|
||
for (let i = 0; i < nodeCount; i++) {
|
||
const baseY = TOP_PAD + i * ROW_H;
|
||
// vertical jitter ±55px so islands feel scattered, not gridded
|
||
const yJitter = (rng() - 0.5) * 110;
|
||
|
||
let best: { x: number; y: number } | null = null;
|
||
let attempts = 0;
|
||
|
||
while (!best || attempts < 40) {
|
||
// Pick a random zone, biased to alternate left/right to keep paths readable
|
||
const zoneIdx =
|
||
i % 2 === 0
|
||
? Math.floor(rng() * 3) // odd rows: left half
|
||
: 2 + Math.floor(rng() * 3); // even rows: right half (clamped below)
|
||
const zone = ZONES[Math.min(zoneIdx, ZONES.length - 1)];
|
||
const candidate = {
|
||
x: Math.round((zone[0] + rng() * (zone[1] - zone[0])) * VW),
|
||
y: Math.round(baseY + yJitter * (attempts < 20 ? 1 : 0.4)),
|
||
};
|
||
|
||
// Enforce minimum spacing
|
||
const tooClose = positions.some((p) => {
|
||
const dx = p.x - candidate.x;
|
||
const dy = p.y - candidate.y;
|
||
return Math.sqrt(dx * dx + dy * dy) < MIN_DIST;
|
||
});
|
||
|
||
if (!tooClose || attempts >= 39) {
|
||
best = candidate;
|
||
break;
|
||
}
|
||
attempts++;
|
||
}
|
||
|
||
positions.push(best!);
|
||
}
|
||
|
||
return positions;
|
||
};
|
||
|
||
// ─── 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;
|
||
background: #060e1f;
|
||
}
|
||
|
||
.qm-header {
|
||
position: relative; z-index: 30; flex-shrink: 0;
|
||
background: rgba(4,10,24,0.94);
|
||
backdrop-filter: blur(20px);
|
||
border-bottom: 1px solid rgba(251,191,36,0.12);
|
||
padding: 1.25rem 1.25rem 0;
|
||
}
|
||
|
||
.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; } }
|
||
|
||
.qm-sea-scroll {
|
||
flex:1; overflow-y:auto; overflow-x:visible;
|
||
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%);
|
||
}
|
||
|
||
.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;} }
|
||
|
||
.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;
|
||
}
|
||
|
||
.qm-map-wrap {
|
||
width: ${VW}px;
|
||
max-width: 100%;
|
||
margin: 0 auto;
|
||
position: relative;
|
||
z-index: 5;
|
||
overflow: visible;
|
||
}
|
||
|
||
.qm-map-svg {
|
||
display: block;
|
||
width: ${VW}px;
|
||
max-width: 100%;
|
||
overflow: visible;
|
||
position: relative;
|
||
z-index: 5;
|
||
}
|
||
|
||
.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:center; 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-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; }
|
||
|
||
.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);
|
||
animation:qmFabFloat 4s ease-in-out infinite;
|
||
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1);
|
||
}
|
||
.qm-fab:hover { transform:scale(1.1) rotate(8deg); }
|
||
@keyframes qmFabFloat { 0%,100%{transform:translateY(0) rotate(-4deg);} 50%{transform:translateY(-7px) rotate(4deg);} }
|
||
|
||
@media (min-width: 1024px) {
|
||
.qm-header { display: none; }
|
||
.qm-fab { display: none; }
|
||
.qm-mobile-sea { display: none; }
|
||
|
||
.qm-screen {
|
||
flex-direction: row;
|
||
overflow:visible;
|
||
padding-left: 270px;
|
||
}
|
||
|
||
.qm-right-sea-scroll .qm-map-wrap {
|
||
width: 420px !important;
|
||
max-width: none !important;
|
||
margin: 0 auto !important;
|
||
min-height: 600px;
|
||
overflow: visible !important;
|
||
}
|
||
|
||
.qm-right-sea-scroll .qm-map-svg {
|
||
width: 420px !important;
|
||
height: auto !important;
|
||
max-width: none !important;
|
||
}
|
||
|
||
.qm-left-panel {
|
||
width: 512px;
|
||
flex-shrink: 0;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(251,191,36,0.12) transparent;
|
||
background:
|
||
radial-gradient(ellipse 120% 40% at 50% 0%, rgba(6,60,130,0.25) 0%, transparent 55%),
|
||
linear-gradient(180deg, #07101f 0%, #040c1a 100%);
|
||
border-right: 1px solid rgba(251,191,36,0.07);
|
||
padding: 1.75rem 1.4rem 3rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1.2rem;
|
||
}
|
||
.qm-left-panel::-webkit-scrollbar { width: 3px; }
|
||
.qm-left-panel::-webkit-scrollbar-thumb { background: rgba(251,191,36,0.12); border-radius: 2px; }
|
||
|
||
.qm-right-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: visible;
|
||
position: relative;
|
||
}
|
||
|
||
.qm-desktop-arc-tabs {
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
overflow-x: auto;
|
||
scrollbar-width: none;
|
||
background: linear-gradient(180deg, rgba(4,12,28,0.82) 0%, rgba(4,12,28,0.55) 70%, transparent 100%);
|
||
backdrop-filter: blur(12px) saturate(1.4);
|
||
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
||
border-bottom: none;
|
||
padding: 0.6rem 1.5rem 1.2rem;
|
||
pointer-events: none;
|
||
}
|
||
.qm-desktop-arc-tabs::-webkit-scrollbar { display: none; }
|
||
|
||
.qm-desktop-arc-tab {
|
||
pointer-events: all;
|
||
flex-shrink: 0;
|
||
display: flex; align-items: center; gap: 0.5rem;
|
||
padding: 0.65rem 1.25rem 0.55rem;
|
||
background: rgba(255,255,255,0.04);
|
||
border-radius: 100px;
|
||
margin-right: 0.4rem;
|
||
cursor: pointer;
|
||
font-family: 'Nunito', sans-serif; font-weight: 800; font-size: 0.92rem;
|
||
color: rgba(255,255,255,0.38);
|
||
border: 1px solid rgba(255,255,255,0.07);
|
||
transition: all 0.22s ease; white-space: nowrap;
|
||
}
|
||
.qm-desktop-arc-tab:hover {
|
||
color: rgba(255,255,255,0.75);
|
||
background: rgba(255,255,255,0.08);
|
||
border-color: rgba(255,255,255,0.12);
|
||
}
|
||
.qm-desktop-arc-tab.active {
|
||
color: var(--arc-accent);
|
||
background: color-mix(in srgb, var(--arc-accent) 12%, transparent);
|
||
border-color: color-mix(in srgb, var(--arc-accent) 40%, transparent);
|
||
box-shadow: 0 0 16px color-mix(in srgb, var(--arc-accent) 20%, transparent), inset 0 1px 0 rgba(255,255,255,0.08);
|
||
}
|
||
.qm-desktop-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;
|
||
}
|
||
|
||
.qm-right-sea-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: visible;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(251,191,36,0.12) transparent;
|
||
}
|
||
.qm-right-sea-scroll::-webkit-scrollbar { width: 4px; }
|
||
.qm-right-sea-scroll::-webkit-scrollbar-thumb { background: rgba(251,191,36,0.12); border-radius: 2px; }
|
||
|
||
.qm-right-sea-scroll .qm-sea { padding: 5.5rem 2rem 5rem; }
|
||
|
||
.qm-arc-banner-name { font-size: 1.6rem; }
|
||
.qm-arc-banner-sub { font-size: 0.88rem; }
|
||
.qm-arc-banner-count { font-size: 0.82rem; }
|
||
.qm-info-title { font-size: 0.92rem; }
|
||
.qm-xp-badge-val { font-size: 0.75rem; }
|
||
.qm-prog-label { font-size: 0.68rem; }
|
||
.qm-claim-btn { font-size: 0.85rem; }
|
||
}
|
||
|
||
@media (max-width: 1023px) {
|
||
.qm-left-panel { display: none !important; }
|
||
.qm-right-panel { display: none !important; }
|
||
.qm-desktop-arc-tabs { display: none !important; }
|
||
.qm-right-sea-scroll { display: none !important; }
|
||
}
|
||
|
||
.qmlp-page-title { font-family:'Sorts Mill Goudy',serif; font-size:1.55rem; font-weight:900; color:#fbbf24; text-shadow:0 0 28px rgba(251,191,36,0.35),0 0 60px rgba(251,191,36,0.08); letter-spacing:0.04em; display:flex; align-items:center; gap:0.5rem; line-height:1; }
|
||
.qmlp-page-sub { font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:700; color:rgba(255,255,255,0.25); letter-spacing:0.14em; text-transform:uppercase; margin-top:0.22rem; }
|
||
.qmlp-rank-card { border-radius:20px; background:linear-gradient(160deg,#0d1b38 0%,#070f20 100%); border:1px solid rgba(255,255,255,0.07); box-shadow:0 8px 32px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.05); overflow:hidden; padding:1.2rem; position:relative; }
|
||
.qmlp-rank-card::before { content:''; position:absolute; inset:0; pointer-events:none; background:repeating-linear-gradient(105deg,transparent 55%,rgba(56,189,248,0.014) 56%,transparent 57%); background-size:300% 300%; animation:qmSeaMove 14s ease-in-out infinite alternate; }
|
||
.qmlp-rank-card::after { content:''; position:absolute; top:-30px; right:-20px; width:140px; height:140px; border-radius:50%; background:radial-gradient(circle,rgba(251,191,36,0.07),transparent 70%); pointer-events:none; }
|
||
.qmlp-rank-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.4rem; position:relative; z-index:1; }
|
||
.qmlp-rank-eyebrow { font-family:'Cinzel',serif; font-size:0.56rem; font-weight:700; letter-spacing:0.2em; text-transform:uppercase; color:rgba(251,191,36,0.45); }
|
||
.qmlp-xp-pill { font-family:'Nunito',sans-serif; font-size:0.72rem; font-weight:900; color:#fbbf24; background:rgba(251,191,36,0.1); border:1px solid rgba(251,191,36,0.16); border-radius:100px; padding:0.18rem 0.6rem; }
|
||
.qmlp-rank-name { font-family:'Cinzel',serif; font-size:1.4rem; font-weight:900; color:#fbbf24; text-shadow:0 0 18px rgba(251,191,36,0.3); display:flex; align-items:center; gap:0.35rem; position:relative; z-index:1; margin-bottom:0.2rem; }
|
||
.qmlp-rank-sub { font-family:'Nunito Sans',sans-serif; font-size:0.66rem; font-weight:700; color:rgba(255,255,255,0.28); position:relative; z-index:1; margin-bottom:0.9rem; }
|
||
.qmlp-ladder-scroll { overflow-x:auto; overflow-y:hidden; scrollbar-width:none; cursor:grab; position:relative; z-index:1; }
|
||
.qmlp-ladder-scroll::-webkit-scrollbar { display:none; }
|
||
.qmlp-ladder-scroll:active { cursor:grabbing; }
|
||
.qmlp-ladder-inner { display:flex; align-items:flex-end; position:relative; height:96px; }
|
||
.qmlp-ladder-baseline { position:absolute; top:46px; left:20px; right:20px; height:2px; background:rgba(255,255,255,0.06); border-radius:2px; z-index:0; }
|
||
.qmlp-ladder-progress { position:absolute; top:46px; left:20px; height:2px; background:linear-gradient(90deg,#fbbf24,#f59e0b); box-shadow:0 0 8px rgba(251,191,36,0.5); border-radius:2px; z-index:1; transition:width 1.2s cubic-bezier(0.34,1.56,0.64,1); }
|
||
.qmlp-ship-wrap { position:absolute; top:16px; z-index:10; display:flex; flex-direction:column; align-items:center; pointer-events:none; transform:translateX(-50%); transition:left 1.2s cubic-bezier(0.34,1.56,0.64,1); }
|
||
.qmlp-ship { font-size:1.3rem; filter:drop-shadow(0 2px 10px rgba(251,191,36,0.6)); animation:qmlpShipBob 2.8s ease-in-out infinite; display:block; }
|
||
@keyframes qmlpShipBob { 0%,100%{transform:translateY(0) rotate(-3deg);} 50%{transform:translateY(-5px) rotate(3deg);} }
|
||
.qmlp-ship-tether { width:1px; height:10px; background:linear-gradient(to bottom,rgba(251,191,36,0.35),transparent); }
|
||
.qmlp-ladder-col { display:flex; flex-direction:column; align-items:center; position:relative; z-index:2; width:72px; flex-shrink:0; }
|
||
.qmlp-ladder-col:first-child,.qmlp-ladder-col:last-child { width:40px; }
|
||
.qmlp-ladder-node { width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:1.2rem; position:relative; z-index:2; margin-top:34px; transition:transform 0.2s; }
|
||
.qmlp-ladder-node:hover { transform:scale(1.1); }
|
||
.qmlp-ladder-node.reached { background:linear-gradient(145deg,#1e0e4a,#3730a3); border:2px solid rgba(251,191,36,0.38); box-shadow:0 0 14px rgba(251,191,36,0.12),0 3px 0 rgba(20,10,50,0.7); }
|
||
.qmlp-ladder-node.current { background:linear-gradient(145deg,#6d28d9,#a855f7); border:2.5px solid #fbbf24; box-shadow:0 0 0 4px rgba(251,191,36,0.1),0 0 18px rgba(168,85,247,0.4),0 3px 0 rgba(80,30,150,0.5); animation:qmlpNodePulse 2.2s ease-in-out infinite; }
|
||
@keyframes qmlpNodePulse { 0%,100%{box-shadow:0 0 0 4px rgba(251,191,36,0.1),0 0 18px rgba(168,85,247,0.4);}50%{box-shadow:0 0 0 7px rgba(251,191,36,0.05),0 0 26px rgba(168,85,247,0.55);} }
|
||
.qmlp-ladder-node.locked { background:rgba(0,0,0,0.4); border:2px solid rgba(255,255,255,0.07); filter:grayscale(0.7) opacity(0.4); }
|
||
.qmlp-ladder-label { margin-top:4px; display:flex; flex-direction:column; align-items:center; gap:1px; text-align:center; }
|
||
.qmlp-ladder-label-name { font-family:'Cinzel',serif; font-size:0.44rem; font-weight:700; max-width:64px; line-height:1.3; letter-spacing:0.03em; }
|
||
.qmlp-ladder-label-name.reached { color:#fbbf24; }
|
||
.qmlp-ladder-label-name.current { color:#c084fc; }
|
||
.qmlp-ladder-label-name.locked { color:rgba(255,255,255,0.16); }
|
||
.qmlp-ladder-label-xp { font-family:'Nunito Sans',sans-serif; font-size:0.38rem; font-weight:700; }
|
||
.qmlp-ladder-label-xp.reached { color:rgba(251,191,36,0.32); }
|
||
.qmlp-ladder-label-xp.current { color:rgba(192,132,252,0.5); }
|
||
.qmlp-ladder-label-xp.locked { color:rgba(255,255,255,0.1); }
|
||
.qmlp-stats-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.6rem; }
|
||
.qmlp-stat-tile { background:rgba(255,255,255,0.035); border:1px solid rgba(255,255,255,0.065); border-radius:16px; padding:0.85rem 1rem; transition:background 0.2s,border-color 0.2s,transform 0.2s; cursor:default; }
|
||
.qmlp-stat-tile:hover { background:rgba(255,255,255,0.06); border-color:rgba(255,255,255,0.1); transform:translateY(-1px); }
|
||
.qmlp-stat-tile.gold { background:rgba(251,191,36,0.06); border-color:rgba(251,191,36,0.18); }
|
||
.qmlp-stat-icon { font-size:1.15rem; margin-bottom:0.35rem; display:block; }
|
||
.qmlp-stat-value { font-family:'Cinzel',serif; font-size:1.35rem; font-weight:900; color:white; line-height:1; margin-bottom:0.18rem; }
|
||
.qmlp-stat-tile.gold .qmlp-stat-value { color:#fbbf24; }
|
||
.qmlp-stat-label { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:700; color:rgba(255,255,255,0.27); text-transform:uppercase; letter-spacing:0.1em; }
|
||
.qmlp-section-title { font-family:'Cinzel',serif; font-size:0.6rem; font-weight:700; letter-spacing:0.2em; text-transform:uppercase; color:rgba(255,255,255,0.22); display:flex; align-items:center; gap:0.5rem; }
|
||
.qmlp-section-title::after { content:''; flex:1; height:1px; background:rgba(255,255,255,0.06); }
|
||
.qmlp-quest-card { background:rgba(255,255,255,0.032); border:1px solid rgba(255,255,255,0.065); border-radius:16px; padding:0.85rem 1rem; cursor:pointer; transition:background 0.15s,border-color 0.15s,transform 0.15s; overflow:hidden; position:relative; }
|
||
.qmlp-quest-card::before { content:''; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--qc-accent,rgba(255,255,255,0.15)); border-radius:0 2px 2px 0; }
|
||
.qmlp-quest-card:hover { background:rgba(255,255,255,0.055); border-color:rgba(255,255,255,0.1); transform:translateX(2px); }
|
||
.qmlp-quest-card.claimable { background:rgba(251,191,36,0.05); border-color:rgba(251,191,36,0.22); animation:qmlpClaimPulse 3s ease-in-out infinite; }
|
||
@keyframes qmlpClaimPulse { 0%,100%{box-shadow:0 0 0 rgba(251,191,36,0);} 50%{box-shadow:0 0 14px rgba(251,191,36,0.1);} }
|
||
.qmlp-qc-top { display:flex; align-items:flex-start; gap:0.6rem; margin-bottom:0.5rem; }
|
||
.qmlp-qc-icon { width:34px; height:34px; border-radius:10px; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:1rem; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.07); }
|
||
.qmlp-quest-card.claimable .qmlp-qc-icon { background:rgba(251,191,36,0.09); border-color:rgba(251,191,36,0.18); animation:hcWiggle 2s ease-in-out infinite; }
|
||
@keyframes hcWiggle { 0%,100%{transform:rotate(0);}30%{transform:rotate(-7deg) scale(1.05);}70%{transform:rotate(7deg) scale(1.05);} }
|
||
.qmlp-qc-body { flex:1; min-width:0; }
|
||
.qmlp-qc-arc { font-family:'Nunito Sans',sans-serif; font-size:0.57rem; font-weight:800; letter-spacing:0.12em; text-transform:uppercase; color:var(--qc-accent,rgba(255,255,255,0.25)); margin-bottom:0.12rem; }
|
||
.qmlp-qc-name { font-family:'Sorts Mill Goudy',serif; font-size:0.88rem; font-weight:700; color:white; line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.qmlp-qc-status { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:700; margin-top:0.12rem; }
|
||
.qmlp-qc-status.ready { color:#fbbf24; }
|
||
.qmlp-qc-status.progress { color:rgba(255,255,255,0.27); }
|
||
.qmlp-qc-prog-row { display:flex; align-items:center; gap:0.5rem; }
|
||
.qmlp-qc-track { flex:1; height:4px; background:rgba(255,255,255,0.065); border-radius:100px; overflow:hidden; }
|
||
.qmlp-qc-fill { height:100%; border-radius:100px; background:var(--qc-accent,rgba(255,255,255,0.35)); transition:width 0.7s cubic-bezier(0.34,1.56,0.64,1); }
|
||
.qmlp-qc-pct { font-family:'Nunito',sans-serif; font-size:0.62rem; font-weight:900; color:rgba(255,255,255,0.35); flex-shrink:0; }
|
||
.qmlp-claim-btn { width:100%; margin-top:0.55rem; padding:0.42rem 0; background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:10px; cursor:pointer; font-family:'Sorts Mill Goudy',serif; font-size:0.74rem; font-weight:700; color:#1a0e00; letter-spacing:0.04em; box-shadow:0 3px 0 #d97706,0 5px 10px rgba(251,191,36,0.22); transition:all 0.12s; }
|
||
.qmlp-claim-btn:hover { transform:translateY(-1px); box-shadow:0 5px 0 #d97706; }
|
||
.qmlp-claim-btn:active { transform:translateY(1px); box-shadow:0 1px 0 #d97706; }
|
||
.qmlp-arc-list { display:flex; flex-direction:column; gap:0.45rem; }
|
||
.qmlp-arc-row { display:flex; align-items:center; gap:0.7rem; padding:0.7rem 0.85rem; border-radius:14px; cursor:pointer; border:1px solid transparent; transition:all 0.2s ease; background:rgba(255,255,255,0.025); }
|
||
.qmlp-arc-row:hover { background:rgba(255,255,255,0.05); border-color:rgba(255,255,255,0.07); }
|
||
.qmlp-arc-row.active { background:rgba(255,255,255,0.055); border-color:var(--arc-accent,rgba(251,191,36,0.25)); box-shadow:inset 0 1px 0 rgba(255,255,255,0.04); }
|
||
.qmlp-arc-emoji { font-size:1.25rem; flex-shrink:0; }
|
||
.qmlp-arc-info { flex:1; min-width:0; }
|
||
.qmlp-arc-name { font-family:'Sorts Mill Goudy',serif; font-size:0.84rem; font-weight:700; color:rgba(255,255,255,0.6); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:0.2rem; }
|
||
.qmlp-arc-row.active .qmlp-arc-name { color:var(--arc-accent,#fbbf24); }
|
||
.qmlp-arc-track { height:3px; background:rgba(255,255,255,0.07); border-radius:100px; overflow:hidden; }
|
||
.qmlp-arc-fill { height:100%; border-radius:100px; background:var(--arc-accent,rgba(255,255,255,0.25)); transition:width 0.6s ease; }
|
||
.qmlp-arc-meta { font-family:'Nunito Sans',sans-serif; font-size:0.55rem; font-weight:700; color:rgba(255,255,255,0.22); margin-top:0.14rem; }
|
||
.qmlp-arc-dot { width:7px; height:7px; border-radius:50%; background:#ef4444; box-shadow:0 0 7px #ef4444; flex-shrink:0; animation:qmDotBlink 1.4s ease-in-out infinite; }
|
||
`;
|
||
|
||
const ERROR_STYLES = `
|
||
.qme-bg {
|
||
position:absolute; inset:0;
|
||
background: radial-gradient(ellipse 80% 60% at 50% 100%, #023e6e 0%, #011a38 50%, #020b18 100%);
|
||
z-index:0;
|
||
}
|
||
.qme-fog {
|
||
position:absolute; pointer-events:none; z-index:1;
|
||
border-radius:50%; filter:blur(60px);
|
||
}
|
||
.qme-fog1 {
|
||
width:500px; height:200px; left:-80px; top:30%;
|
||
background:rgba(0,119,182,0.12);
|
||
animation: qmeFogDrift 18s ease-in-out infinite alternate;
|
||
}
|
||
.qme-fog2 {
|
||
width:400px; height:160px; right:-60px; top:50%;
|
||
background:rgba(0,96,145,0.09);
|
||
animation: qmeFogDrift 22s ease-in-out infinite alternate-reverse;
|
||
}
|
||
@keyframes qmeFogDrift { 0%{transform:translate(0,0);} 100%{transform:translate(40px,-20px);} }
|
||
|
||
.qme-debris {
|
||
position:absolute; border-radius:2px; pointer-events:none; z-index:2;
|
||
background:rgba(100,65,35,0.55);
|
||
}
|
||
.qme-debris-0 { width:22px;height:5px; left:12%; top:58%; animation:qmeDebrisBob 5.1s ease-in-out infinite; }
|
||
.qme-debris-1 { width:14px;height:4px; left:78%; top:62%; animation:qmeDebrisBob 4.3s ease-in-out infinite 0.7s; }
|
||
.qme-debris-2 { width:8px; height:8px; border-radius:50%; left:22%; top:72%; background:rgba(251,191,36,0.18); animation:qmeDebrisBob 6.2s ease-in-out infinite 1.1s; }
|
||
.qme-debris-3 { width:18px;height:4px; left:64%; top:54%; animation:qmeDebrisBob 4.8s ease-in-out infinite 0.3s; }
|
||
.qme-debris-4 { width:6px; height:6px; border-radius:50%; left:85%; top:44%; background:rgba(0,180,216,0.25); animation:qmeDebrisBob 3.9s ease-in-out infinite 1.8s; }
|
||
.qme-debris-5 { width:10px;height:3px; left:8%; top:80%; animation:qmeDebrisBob 5.5s ease-in-out infinite 0.5s; }
|
||
.qme-debris-6 { width:24px;height:5px; left:48%; top:76%; animation:qmeDebrisBob 6.7s ease-in-out infinite 2.2s; }
|
||
.qme-debris-7 { width:7px; height:7px; border-radius:50%; left:35%; top:45%; background:rgba(100,65,35,0.3); animation:qmeDebrisBob 4.1s ease-in-out infinite 1.4s; }
|
||
@keyframes qmeDebrisBob { 0%,100%{transform:translateY(0) rotate(0deg);opacity:0.7;} 50%{transform:translateY(-9px) rotate(6deg);opacity:1;} }
|
||
|
||
.qme-stage {
|
||
position:relative; z-index:10; display:flex; flex-direction:column; align-items:center;
|
||
padding:1rem 2rem 3rem; max-width:380px; width:100%;
|
||
animation:qmeStageIn 0.9s cubic-bezier(0.22,1,0.36,1) both;
|
||
}
|
||
@keyframes qmeStageIn { 0%{opacity:0;transform:translateY(28px);} 100%{opacity:1;transform:translateY(0);} }
|
||
|
||
.qme-ship-wrap {
|
||
width:240px; margin-bottom:0.4rem;
|
||
animation:qmeShipRock 5s ease-in-out infinite;
|
||
transform-origin:center bottom;
|
||
filter:drop-shadow(0 12px 40px rgba(0,80,160,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.8));
|
||
}
|
||
@keyframes qmeShipRock { 0%,100%{transform:rotate(-2.5deg) translateY(0);} 50%{transform:rotate(2.5deg) translateY(-4px);} }
|
||
.qme-ship-svg { width:100%; height:auto; display:block; }
|
||
.qme-mast { animation:qmeMastSway 7s ease-in-out infinite; transform-origin:130px 148px; }
|
||
@keyframes qmeMastSway { 0%,100%{transform:rotate(-1deg);} 50%{transform:rotate(1.5deg);} }
|
||
.qme-hull { animation:qmeHullSettle 5s ease-in-out infinite 0.3s; transform-origin:120px 145px; }
|
||
@keyframes qmeHullSettle { 0%,100%{transform:rotate(-1.5deg);} 50%{transform:rotate(1deg);} }
|
||
|
||
.qme-content {
|
||
text-align:center; display:flex; flex-direction:column; align-items:center; gap:0.55rem;
|
||
animation:qmeStageIn 1s cubic-bezier(0.22,1,0.36,1) 0.12s both;
|
||
}
|
||
|
||
.qme-eyebrow {
|
||
font-family:'Cinzel',serif; font-size:0.58rem; font-weight:700;
|
||
letter-spacing:0.28em; text-transform:uppercase; color:rgba(251,191,36,0.5);
|
||
}
|
||
|
||
.qme-title {
|
||
font-family:'Sorts Mill Goudy',serif; font-size:2.4rem; font-weight:900; margin:0;
|
||
color:#ffffff; letter-spacing:0.02em;
|
||
text-shadow:0 0 40px rgba(0,150,220,0.45), 0 2px 20px rgba(0,0,0,0.8);
|
||
line-height:1.05;
|
||
}
|
||
|
||
.qme-subtitle {
|
||
font-family:'Nunito Sans',sans-serif; font-size:0.82rem; font-weight:600; margin:0;
|
||
color:rgba(255,255,255,0.38); max-width:260px; line-height:1.5;
|
||
}
|
||
|
||
.qme-error-badge {
|
||
display:flex; align-items:center; gap:0.45rem;
|
||
background:rgba(239,68,68,0.08); border:1px solid rgba(239,68,68,0.25);
|
||
border-radius:100px; padding:0.32rem 0.85rem;
|
||
max-width:100%; overflow:hidden;
|
||
}
|
||
.qme-error-dot {
|
||
width:6px; height:6px; border-radius:50%; background:#ef4444;
|
||
box-shadow:0 0 8px #ef4444; flex-shrink:0;
|
||
animation:qmDotBlink 1.4s ease-in-out infinite;
|
||
}
|
||
.qme-error-text {
|
||
font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:700;
|
||
color:rgba(239,68,68,0.85); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||
}
|
||
|
||
.qme-retry-btn {
|
||
position:relative; overflow:hidden;
|
||
margin-top:0.35rem; padding:0.7rem 2rem;
|
||
background:linear-gradient(135deg,#0077b6,#023e8a);
|
||
border:1px solid rgba(0,180,216,0.45); border-radius:100px; cursor:pointer;
|
||
font-family:'Sorts Mill Goudy',serif; font-size:0.88rem; font-weight:700; letter-spacing:0.06em;
|
||
color:white;
|
||
box-shadow:0 4px 0 rgba(0,30,80,0.6), 0 6px 28px rgba(0,100,200,0.25);
|
||
transition:all 0.14s ease;
|
||
}
|
||
.qme-retry-btn:hover { transform:translateY(-2px); box-shadow:0 6px 0 rgba(0,30,80,0.6), 0 10px 32px rgba(0,120,220,0.35); }
|
||
.qme-retry-btn:active { transform:translateY(1px); box-shadow:0 2px 0 rgba(0,30,80,0.6); }
|
||
.qme-btn-wake {
|
||
position:absolute; inset:0; border-radius:100px;
|
||
background:linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.12) 50%, transparent 100%);
|
||
transform:translateX(-100%);
|
||
animation:qmeBtnSheen 3.5s ease-in-out infinite 1s;
|
||
}
|
||
@keyframes qmeBtnSheen { 0%,100%{transform:translateX(-100%);} 40%,60%{transform:translateX(100%);} }
|
||
.qme-btn-label { position:relative; z-index:1; }
|
||
|
||
.qme-hint {
|
||
font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:600; margin:0;
|
||
color:rgba(255,255,255,0.18); max-width:240px; line-height:1.5; font-style:italic;
|
||
}
|
||
`;
|
||
|
||
// ─── Arc theme ────────────────────────────────────────────────────────────────
|
||
export interface ArcTheme {
|
||
accent: string;
|
||
accentDark: string;
|
||
bgFrom: string;
|
||
bgTo: string;
|
||
emoji: string;
|
||
terrain: { l: string; m: string; d: string; s: string };
|
||
decos: [string, string, string];
|
||
}
|
||
|
||
const DECO_SETS: [string, string, string][] = [
|
||
["🌴", "🌿", "🌴"],
|
||
["🌵", "🏺", "🌵"],
|
||
["☁️", "✨", "☁️"],
|
||
["🪨", "🌾", "🪨"],
|
||
["🍄", "🌸", "🍄"],
|
||
["🔥", "💀", "🔥"],
|
||
["❄️", "🌨️", "❄️"],
|
||
["🌺", "🦜", "🌺"],
|
||
];
|
||
|
||
const hslToHex = (h: number, s: number, l: number) => {
|
||
const a = s * Math.min(l, 1 - l);
|
||
const f = (n: number) => {
|
||
const k = (n + h * 12) % 12;
|
||
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||
return Math.round(255 * c)
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
};
|
||
return `#${f(0)}${f(8)}${f(4)}`;
|
||
};
|
||
|
||
export const generateArcTheme = (arc: QuestArc): ArcTheme => {
|
||
const rng = mkRng(strToSeed(arc.id));
|
||
const anchors = [150, 165, 180, 200, 230, 260];
|
||
const baseHue =
|
||
anchors[Math.floor(rng() * anchors.length)] + (rng() - 0.5) * 8;
|
||
const satBase = 0.48 + rng() * 0.18;
|
||
const satTerrain = Math.min(0.8, satBase + 0.12);
|
||
const accentLightL = 0.48 + rng() * 0.12;
|
||
const accentDarkL = 0.22 + rng() * 0.06;
|
||
const bgFromL = 0.04 + rng() * 0.06;
|
||
const bgToL = 0.1 + rng() * 0.06;
|
||
const accent = hslToHex(baseHue, satBase, accentLightL);
|
||
const accentDark = hslToHex(
|
||
baseHue + (rng() * 6 - 3),
|
||
Math.max(0.35, satBase - 0.08),
|
||
accentDarkL,
|
||
);
|
||
const bgFrom = hslToHex(
|
||
baseHue + (rng() * 10 - 5),
|
||
0.1 + rng() * 0.06,
|
||
bgFromL,
|
||
);
|
||
const bgTo = hslToHex(baseHue + (6 + rng() * 12), 0.08 + rng() * 0.06, bgToL);
|
||
const tL = hslToHex(
|
||
baseHue + 10 + rng() * 6,
|
||
Math.min(0.85, satTerrain),
|
||
0.36 + rng() * 0.08,
|
||
);
|
||
const tM = hslToHex(
|
||
baseHue + (rng() * 6 - 3),
|
||
Math.min(0.72, satTerrain - 0.06),
|
||
0.24 + rng() * 0.06,
|
||
);
|
||
const tD = hslToHex(
|
||
baseHue + (rng() * 8 - 4),
|
||
Math.max(0.38, satBase - 0.18),
|
||
0.1 + rng() * 0.04,
|
||
);
|
||
const sd = parseInt(tD.slice(1, 3), 16);
|
||
const sg = parseInt(tD.slice(3, 5), 16);
|
||
const sb = parseInt(tD.slice(5, 7), 16);
|
||
const emojis = ["🌿", "🌲", "🌳", "🌺", "🪨", "🍄", "🌵"];
|
||
const emoji = emojis[Math.floor(rng() * emojis.length)];
|
||
return {
|
||
accent,
|
||
accentDark,
|
||
bgFrom,
|
||
bgTo,
|
||
emoji,
|
||
terrain: { l: tL, m: tM, d: tD, s: `rgba(${sd},${sg},${sb},0.6)` },
|
||
decos: DECO_SETS[Math.floor(rng() * DECO_SETS.length)],
|
||
};
|
||
};
|
||
|
||
const themeCache = new Map<string, ArcTheme>();
|
||
const getArcTheme = (arc: QuestArc): ArcTheme => {
|
||
if (!themeCache.has(arc.id)) themeCache.set(arc.id, generateArcTheme(arc));
|
||
return themeCache.get(arc.id)!;
|
||
};
|
||
|
||
const REQ_EMOJI: Record<string, string> = {
|
||
questions: "❓",
|
||
accuracy: "🎯",
|
||
streak: "🔥",
|
||
sessions: "📚",
|
||
topics: "🗺️",
|
||
xp: "⚡",
|
||
leaderboard: "🏆",
|
||
};
|
||
const REQ_LABEL: Record<string, string> = {
|
||
questions: "questions answered",
|
||
accuracy: "% accuracy",
|
||
streak: "day streak",
|
||
sessions: "sessions",
|
||
topics: "topics covered",
|
||
xp: "XP earned",
|
||
leaderboard: "leaderboard rank",
|
||
};
|
||
|
||
// ─── Crew ranks ───────────────────────────────────────────────────────────────
|
||
const CREW_RANKS = [
|
||
{ id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 },
|
||
{ id: "navigator", label: "Navigator", emoji: "📚", xpRequired: 500 },
|
||
{ id: "first_mate", label: "First Mate", emoji: "🎓", xpRequired: 1500 },
|
||
{ id: "warlord", label: "Warlord", emoji: "⚔️", xpRequired: 3000 },
|
||
{ id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 },
|
||
{ id: "pirate_king", label: "Pirate King", emoji: "☠️", xpRequired: 10000 },
|
||
];
|
||
|
||
const SEG_W = 72,
|
||
EDGE_W = 40;
|
||
const nodeX = (i: number, total: number) => {
|
||
if (i === 0) return EDGE_W / 2;
|
||
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
|
||
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
|
||
};
|
||
|
||
// ─── 3D Route Paths ───────────────────────────────────────────────────────────
|
||
// Proper Three.js sea routes — live in world space, move with camera pan/zoom/rotate.
|
||
// Each segment is a CatmullRomCurve3 that bows sideways over the water.
|
||
// Dashes scroll via LineDashedMaterial dashOffset animated in useFrame.
|
||
// Active legs get a glow tube + a ship that physically travels the curve.
|
||
|
||
interface RouteSegmentProps {
|
||
from: THREE.Vector3;
|
||
to: THREE.Vector3;
|
||
idx: number;
|
||
isDone: boolean;
|
||
isActive: boolean;
|
||
isNext: boolean;
|
||
accent: string;
|
||
}
|
||
|
||
const RouteSegment = ({
|
||
from,
|
||
to,
|
||
idx,
|
||
isDone,
|
||
isActive,
|
||
isNext,
|
||
accent,
|
||
}: RouteSegmentProps) => {
|
||
const lineRef = useRef<THREE.Line | null>(null);
|
||
const glowRef = useRef<THREE.Mesh | null>(null);
|
||
const shipRef = useRef<THREE.Group | null>(null);
|
||
const shipT = useRef(0);
|
||
|
||
// CatmullRom curve bowing sideways — alternate direction per segment
|
||
const curve = useMemo(() => {
|
||
const side = idx % 2 === 0 ? 1 : -1;
|
||
const dx = to.x - from.x;
|
||
const dz = to.z - from.z;
|
||
const mid = new THREE.Vector3(
|
||
(from.x + to.x) / 2 + -dz * 0.28 * side,
|
||
-0.65, // hover just above water
|
||
(from.z + to.z) / 2 + dx * 0.28 * side,
|
||
);
|
||
const y = -0.72;
|
||
const p0 = new THREE.Vector3(from.x, y, from.z);
|
||
const p1 = new THREE.Vector3(to.x, y, to.z);
|
||
return new THREE.CatmullRomCurve3([p0, mid, p1], false, "catmullrom", 0.5);
|
||
}, [from, to, idx]);
|
||
|
||
// Geometry for the dashed line (needs computeLineDistances)
|
||
const lineGeo = useMemo(() => {
|
||
const pts = curve.getPoints(80);
|
||
return new THREE.BufferGeometry().setFromPoints(pts);
|
||
}, [curve]);
|
||
|
||
// Tube geometry for the soft glow underlayer
|
||
const glowGeo = useMemo(
|
||
() => new THREE.TubeGeometry(curve, 40, 0.055, 5, false),
|
||
[curve],
|
||
);
|
||
|
||
const color = new THREE.Color(
|
||
isDone || isNext ? accent : isActive ? "#94d8ff" : "#ffffff",
|
||
);
|
||
const opacity = isDone || isNext ? 0.8 : isActive ? 0.85 : 0.15;
|
||
const glowOpacity = isDone || isNext ? 0.2 : isActive ? 0.25 : 0;
|
||
const dashSpeed = isDone || isNext ? 0.55 : isActive ? 1.05 : 0;
|
||
|
||
useFrame((_, dt) => {
|
||
// Scroll dashes forward along the route
|
||
if (lineRef.current) {
|
||
// material typings may not include dashOffset; use any and guard the value
|
||
const lineMat = lineRef.current.material as any;
|
||
if (dashSpeed > 0)
|
||
lineMat.dashOffset = (lineMat.dashOffset ?? 0) - dt * dashSpeed;
|
||
}
|
||
// Pulse glow on active segments
|
||
if (glowRef.current && (isActive || isNext)) {
|
||
const mat = glowRef.current.material as THREE.MeshStandardMaterial;
|
||
mat.emissiveIntensity = 0.28 + Math.sin(Date.now() * 0.0018) * 0.12;
|
||
}
|
||
// Travel ship along active curve
|
||
if (shipRef.current && isActive && !isDone) {
|
||
shipT.current = (shipT.current + dt * 0.11) % 1;
|
||
const pt = curve.getPoint(shipT.current);
|
||
const tan = curve.getTangent(shipT.current);
|
||
shipRef.current.position.copy(pt);
|
||
shipRef.current.position.y = -0.58; // float just above water surface
|
||
shipRef.current.rotation.y = Math.atan2(tan.x, tan.z);
|
||
}
|
||
});
|
||
|
||
return (
|
||
<group>
|
||
{/* Glow underlayer tube */}
|
||
{(isDone || isNext || isActive) && (
|
||
<mesh ref={glowRef} geometry={glowGeo}>
|
||
<meshStandardMaterial
|
||
color={color}
|
||
emissive={color}
|
||
emissiveIntensity={0.28}
|
||
transparent
|
||
opacity={glowOpacity}
|
||
depthWrite={false}
|
||
side={THREE.DoubleSide}
|
||
/>
|
||
</mesh>
|
||
)}
|
||
|
||
{/* Dashed route line */}
|
||
<line
|
||
ref={(r: any) => {
|
||
// r may be an SVGLineElement in JSX DOM typings; treat as any to satisfy TS and assign to Line ref
|
||
lineRef.current = r as THREE.Line | null;
|
||
}}
|
||
// @ts-ignore - geometry is a three.js prop, not an SVG attribute
|
||
geometry={lineGeo}
|
||
// onUpdate receives a three.js Line; use any to avoid DOM typings
|
||
onUpdate={(self: any) => self.computeLineDistances()}
|
||
>
|
||
<lineDashedMaterial
|
||
color={color}
|
||
dashSize={0.2}
|
||
gapSize={0.13}
|
||
opacity={opacity}
|
||
transparent
|
||
/>
|
||
</line>
|
||
|
||
{/* Travelling ship on the active leg */}
|
||
{isActive && !isDone && (
|
||
<group ref={shipRef}>
|
||
<Billboard>
|
||
<Text fontSize={0.3} anchorX="center" anchorY="middle">
|
||
⛵
|
||
</Text>
|
||
</Billboard>
|
||
{/* Gold wake glow disk */}
|
||
<mesh rotation-x={-Math.PI / 2} position-y={0.04}>
|
||
<circleGeometry args={[0.19, 16]} />
|
||
<meshStandardMaterial
|
||
color="#fbbf24"
|
||
emissive="#fbbf24"
|
||
emissiveIntensity={1.0}
|
||
transparent
|
||
opacity={0.38}
|
||
depthWrite={false}
|
||
/>
|
||
</mesh>
|
||
</group>
|
||
)}
|
||
</group>
|
||
);
|
||
};
|
||
|
||
// ── RoutePaths3D — all route segments, lives inside <Canvas> ─────────────────
|
||
interface RoutePaths3DProps {
|
||
nodes: QuestNode[];
|
||
positions: { x: number; y: number }[];
|
||
nodeCount: number;
|
||
accent: string;
|
||
}
|
||
|
||
const RoutePaths3D = ({
|
||
nodes,
|
||
positions,
|
||
nodeCount,
|
||
accent,
|
||
}: RoutePaths3DProps) => {
|
||
// Mirror the exact same 2D→3D coordinate mapping used in IslandScene
|
||
const pos3d = useMemo(
|
||
() =>
|
||
positions.map(
|
||
(p, i) =>
|
||
new THREE.Vector3(
|
||
(p.x / VW) * 9 - 4.5,
|
||
0,
|
||
(i / Math.max(nodeCount - 1, 1)) * (nodeCount * 2.4),
|
||
),
|
||
),
|
||
[positions, nodeCount],
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{nodes.slice(0, -1).map((node, i) => {
|
||
const from = pos3d[i];
|
||
const to = pos3d[i + 1];
|
||
if (!from || !to) return null;
|
||
const nextNode = nodes[i + 1];
|
||
const isDone = node.status === "completed";
|
||
const isActive =
|
||
node.status === "ACTIVE" || node.status === "CLAIMABLE";
|
||
const isNext =
|
||
isDone &&
|
||
(nextNode?.status === "ACTIVE" || nextNode?.status === "CLAIMABLE");
|
||
return (
|
||
<RouteSegment
|
||
key={`r3d-${i}`}
|
||
from={from}
|
||
to={to}
|
||
idx={i}
|
||
isDone={isDone}
|
||
isActive={isActive}
|
||
isNext={isNext}
|
||
accent={accent}
|
||
/>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ─── Map geometry ─────────────────────────────────────────────────────────────
|
||
// Camera constants
|
||
const POLAR_FIXED = Math.PI / 2 - (25 * Math.PI) / 180;
|
||
const DIST_DEFAULT = 7;
|
||
const DIST_MIN = 3.5;
|
||
const DIST_MAX = 14;
|
||
const PAN_RADIUS = 8;
|
||
|
||
// ── WaterPlane ────────────────────────────────────────────────────────────────
|
||
const WaterPlane = () => {
|
||
const meshRef = useRef<THREE.Mesh>(null!);
|
||
useFrame(({ clock }) => {
|
||
const geo = meshRef.current?.geometry as THREE.PlaneGeometry;
|
||
if (!geo) return;
|
||
const pos = geo.attributes.position;
|
||
const t = clock.elapsedTime;
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const x = pos.getX(i);
|
||
const z = pos.getZ(i);
|
||
pos.setY(
|
||
i,
|
||
Math.sin(x * 0.6 + t * 0.7) * 0.04 + Math.cos(z * 0.5 + t * 0.5) * 0.03,
|
||
);
|
||
}
|
||
pos.needsUpdate = true;
|
||
geo.computeVertexNormals();
|
||
});
|
||
return (
|
||
<mesh
|
||
ref={meshRef}
|
||
rotation-x={-Math.PI / 2}
|
||
position-y={-0.85}
|
||
receiveShadow
|
||
>
|
||
<planeGeometry args={[120, 120, 80, 80]} />
|
||
<meshStandardMaterial
|
||
color="#03045e"
|
||
roughness={0.08}
|
||
metalness={0.35}
|
||
emissive="#03045e"
|
||
emissiveIntensity={0.55}
|
||
/>
|
||
</mesh>
|
||
);
|
||
};
|
||
|
||
// ── PanClamp ──────────────────────────────────────────────────────────────────
|
||
const PanClamp = () => {
|
||
const controls = useThree((s) => s.controls) as any;
|
||
useFrame(() => {
|
||
if (!controls?.target) return;
|
||
const t = controls.target as THREE.Vector3;
|
||
const dx = t.x,
|
||
dz = t.z;
|
||
const d = Math.sqrt(dx * dx + dz * dz);
|
||
if (d > PAN_RADIUS) {
|
||
const sc = PAN_RADIUS / d;
|
||
t.x *= sc;
|
||
t.z *= sc;
|
||
}
|
||
t.y = THREE.MathUtils.clamp(t.y, -1, 2);
|
||
});
|
||
return null;
|
||
};
|
||
|
||
// ── CameraDistSync ─────────────────────────────────────────────────────────────
|
||
const CameraDistSync = ({ dist }: { dist: number }) => {
|
||
const { camera, controls } = useThree() as any;
|
||
useEffect(() => {
|
||
if (!camera) return;
|
||
const target = controls?.target ?? new THREE.Vector3(0, 0, 0);
|
||
const dir = camera.position.clone().sub(target).normalize();
|
||
camera.position.copy(target).addScaledVector(dir, dist);
|
||
}, [camera, controls, dist]);
|
||
return null;
|
||
};
|
||
|
||
// ── IslandScene ───────────────────────────────────────────────────────────────
|
||
interface MapContentProps {
|
||
arc: QuestArc;
|
||
theme: ArcTheme;
|
||
positions: { x: number; y: number }[];
|
||
onNodeTap: (node: QuestNode) => void;
|
||
onClaim: (node: QuestNode) => void;
|
||
initialTarget: [number, number, number];
|
||
modalOpen: boolean;
|
||
}
|
||
|
||
const IslandScene = ({
|
||
arc,
|
||
theme,
|
||
positions,
|
||
onNodeTap,
|
||
onClaim,
|
||
initialTarget,
|
||
modalOpen,
|
||
}: MapContentProps) => {
|
||
const nodeCount = arc.nodes.length;
|
||
|
||
const sorted = useMemo(
|
||
() => [...arc.nodes].sort((a, b) => a.sequence_order - b.sequence_order),
|
||
[arc.nodes],
|
||
);
|
||
|
||
const currentIdx = sorted.findIndex(
|
||
(n) => n.status === "ACTIVE" || n.status === "CLAIMABLE",
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<WaterPlane />
|
||
<mesh>
|
||
<sphereGeometry args={[55, 32, 16]} />
|
||
<meshBasicMaterial color="#87ceeb" side={THREE.BackSide} />
|
||
</mesh>
|
||
|
||
{/* 3D sea routes — live in world space, respect camera */}
|
||
<RoutePaths3D
|
||
nodes={sorted}
|
||
positions={positions}
|
||
nodeCount={nodeCount}
|
||
accent={theme.accent}
|
||
/>
|
||
|
||
{sorted.map((node, i) => {
|
||
const centre = positions[i] ?? { x: VW / 2, y: TOP_PAD + i * ROW_H };
|
||
const x3 = (centre.x / VW) * 9 - 4.5;
|
||
const z3 = (i / Math.max(nodeCount - 1, 1)) * (nodeCount * 2.4);
|
||
return (
|
||
<Island3D
|
||
key={node.node_id}
|
||
node={node}
|
||
index={i}
|
||
position={[x3, 0, z3]}
|
||
accent={theme.accent}
|
||
terrain={theme.terrain}
|
||
onTap={onNodeTap}
|
||
onClaim={onClaim}
|
||
isCurrent={i === currentIdx} // ← add this
|
||
modalOpen={modalOpen}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
<ambientLight intensity={2.5} color="#ffffff" />
|
||
<directionalLight
|
||
position={[8, 14, 6]}
|
||
intensity={3.5}
|
||
color="#ffe8a0"
|
||
castShadow
|
||
shadow-mapSize-width={1024}
|
||
shadow-mapSize-height={1024}
|
||
shadow-camera-near={0.1}
|
||
shadow-camera-far={80}
|
||
shadow-camera-left={-20}
|
||
shadow-camera-right={20}
|
||
shadow-camera-top={20}
|
||
shadow-camera-bottom={-20}
|
||
/>
|
||
<directionalLight
|
||
position={[-5, 8, -4]}
|
||
intensity={1.2}
|
||
color="#b0d8f5"
|
||
/>
|
||
<pointLight
|
||
position={[0, -0.5, 3]}
|
||
intensity={1.8}
|
||
color="#7dcfef"
|
||
distance={30}
|
||
/>
|
||
|
||
<Stars
|
||
radius={70}
|
||
depth={35}
|
||
count={600}
|
||
factor={3}
|
||
saturation={0}
|
||
fade
|
||
speed={0.3}
|
||
/>
|
||
|
||
<OrbitControls
|
||
makeDefault
|
||
enableZoom
|
||
enablePan
|
||
enableRotate
|
||
minDistance={DIST_MIN}
|
||
maxDistance={DIST_MAX}
|
||
minPolarAngle={POLAR_FIXED - (8 * Math.PI) / 180}
|
||
maxPolarAngle={POLAR_FIXED + (8 * Math.PI) / 180}
|
||
dampingFactor={0.07}
|
||
enableDamping
|
||
zoomSpeed={0.65}
|
||
panSpeed={0.7}
|
||
screenSpacePanning={false}
|
||
target={new THREE.Vector3(...initialTarget)}
|
||
/>
|
||
<PanClamp />
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ── Zoom button ───────────────────────────────────────────────────────────────
|
||
const zoomBtnStyle: React.CSSProperties = {
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: "50%",
|
||
background: "rgba(255,255,255,0.08)",
|
||
border: "1px solid rgba(255,255,255,0.18)",
|
||
color: "rgba(255,255,255,0.75)",
|
||
fontSize: "1.15rem",
|
||
lineHeight: "1",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: "pointer",
|
||
fontFamily: "monospace",
|
||
transition: "background 0.15s, transform 0.1s",
|
||
userSelect: "none",
|
||
padding: 0,
|
||
};
|
||
|
||
// ── MapContent ────────────────────────────────────────────────────────────────
|
||
const MapContent = ({
|
||
arc,
|
||
theme,
|
||
positions,
|
||
onNodeTap,
|
||
onClaim,
|
||
initialTarget,
|
||
modalOpen,
|
||
}: MapContentProps) => {
|
||
const nodeCount = arc.nodes.length;
|
||
const isMobile = window.innerWidth < 768;
|
||
const offset = isMobile ? 150 : 0;
|
||
const canvasH = Math.max(window.innerHeight - offset, nodeCount * 160);
|
||
const [dist, setDist] = useState(DIST_DEFAULT);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
width: "100%",
|
||
height: `${canvasH}px`,
|
||
background: "#023047",
|
||
}}
|
||
>
|
||
{/* Zoom buttons */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
bottom: "1.5rem",
|
||
right: "1rem",
|
||
zIndex: 10,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "0.4rem",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<button
|
||
aria-label="Zoom in"
|
||
style={zoomBtnStyle}
|
||
onClick={() => setDist((d) => Math.max(DIST_MIN, d - 1.5))}
|
||
>
|
||
+
|
||
</button>
|
||
<div
|
||
style={{
|
||
width: 3,
|
||
height: 56,
|
||
background: "rgba(255,255,255,0.08)",
|
||
borderRadius: 4,
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
bottom: 0,
|
||
width: "100%",
|
||
borderRadius: 4,
|
||
background: "rgba(251,191,36,0.65)",
|
||
height: `${((DIST_MAX - dist) / (DIST_MAX - DIST_MIN)) * 100}%`,
|
||
transition: "height 0.2s ease",
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
aria-label="Zoom out"
|
||
style={zoomBtnStyle}
|
||
onClick={() => setDist((d) => Math.min(DIST_MAX, d + 1.5))}
|
||
>
|
||
−
|
||
</button>
|
||
</div>
|
||
|
||
<Canvas
|
||
camera={{
|
||
position: [
|
||
initialTarget[0] + Math.sin(0) * DIST_DEFAULT,
|
||
Math.sin(POLAR_FIXED) * DIST_DEFAULT,
|
||
initialTarget[2] + Math.cos(POLAR_FIXED) * DIST_DEFAULT,
|
||
],
|
||
fov: 52,
|
||
near: 0.1,
|
||
far: 300,
|
||
}}
|
||
gl={{ antialias: true, alpha: false }}
|
||
style={{ width: "100%", height: "100%", background: "#023047" }}
|
||
shadows
|
||
onPointerMissed={() => {}}
|
||
>
|
||
<fog attach="fog" args={["#023047", 18, 60]} />
|
||
<CameraDistSync dist={dist} />
|
||
<IslandScene
|
||
arc={arc}
|
||
theme={theme}
|
||
positions={positions}
|
||
onNodeTap={onNodeTap}
|
||
onClaim={onClaim}
|
||
initialTarget={initialTarget}
|
||
modalOpen={modalOpen}
|
||
/>
|
||
</Canvas>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── Left Panel ───────────────────────────────────────────────────────────────
|
||
const LeftPanel = ({
|
||
arcs,
|
||
activeArcId,
|
||
onSelectArc,
|
||
scrollRef,
|
||
user,
|
||
onClaim,
|
||
}: {
|
||
arcs: QuestArc[];
|
||
activeArcId: string;
|
||
onSelectArc: (id: string) => void;
|
||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||
user: any;
|
||
onClaim: (n: QuestNode) => void;
|
||
}) => {
|
||
const sortedArcs = [...arcs].sort(
|
||
(a, b) => a.sequence_order - b.sequence_order,
|
||
);
|
||
const earnedXP = user?.total_xp ?? 0,
|
||
streak = user?.streak ?? user?.current_streak ?? 0,
|
||
level = user?.current_level ?? 1;
|
||
const totalDone = arcs.reduce(
|
||
(s, a) => s + a.nodes.filter((n) => n.status === "completed").length,
|
||
0,
|
||
);
|
||
const totalNodes = arcs.reduce((s, a) => s + a.nodes.length, 0);
|
||
const claimable = arcs.reduce(
|
||
(s, a) => s + a.nodes.filter((n) => n.status === "CLAIMABLE").length,
|
||
0,
|
||
);
|
||
|
||
const activeNodes: { node: QuestNode; arc: QuestArc }[] = [];
|
||
for (const a of arcs)
|
||
for (const n of a.nodes)
|
||
if (n.status === "CLAIMABLE" || n.status === "ACTIVE")
|
||
activeNodes.push({ node: n, arc: a });
|
||
activeNodes.sort((a, b) =>
|
||
a.node.status === "CLAIMABLE" && b.node.status !== "CLAIMABLE"
|
||
? -1
|
||
: b.node.status === "CLAIMABLE" && a.node.status !== "CLAIMABLE"
|
||
? 1
|
||
: 0,
|
||
);
|
||
|
||
const ladder = CREW_RANKS,
|
||
N = ladder.length;
|
||
let currentIdx = 0;
|
||
for (let i = N - 1; i >= 0; i--)
|
||
if (earnedXP >= ladder[i].xpRequired) {
|
||
currentIdx = i;
|
||
break;
|
||
}
|
||
const current = ladder[currentIdx],
|
||
nextRank = ladder[currentIdx + 1] ?? null;
|
||
const progToNext = nextRank
|
||
? Math.min(
|
||
1,
|
||
(earnedXP - current.xpRequired) /
|
||
(nextRank.xpRequired - current.xpRequired),
|
||
)
|
||
: 1;
|
||
const shipXPos = nextRank
|
||
? nodeX(currentIdx, N) +
|
||
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progToNext
|
||
: nodeX(currentIdx, N);
|
||
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
|
||
const [animated, setAnimated] = useState(false);
|
||
const ladderRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const id = requestAnimationFrame(() =>
|
||
requestAnimationFrame(() => setAnimated(true)),
|
||
);
|
||
return () => cancelAnimationFrame(id);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!ladderRef.current) return;
|
||
ladderRef.current.scrollTo({
|
||
left: Math.max(0, shipXPos - ladderRef.current.offsetWidth / 2),
|
||
behavior: "smooth",
|
||
});
|
||
}, [shipXPos]);
|
||
|
||
const rankPct = Math.round(progToNext * 100);
|
||
|
||
return (
|
||
<div className="qm-left-panel">
|
||
<div>
|
||
<div className="qmlp-page-title">🏴☠️ Quest Map</div>
|
||
<div className="qmlp-page-sub">Track your voyage</div>
|
||
</div>
|
||
<div className="qmlp-rank-card">
|
||
<div className="qmlp-rank-header">
|
||
<span className="qmlp-rank-eyebrow">⚓ Crew Rank</span>
|
||
<span className="qmlp-xp-pill">{earnedXP.toLocaleString()} XP</span>
|
||
</div>
|
||
<div className="qmlp-rank-name">
|
||
{current.emoji} {current.label}
|
||
</div>
|
||
<div className="qmlp-rank-sub">
|
||
{nextRank
|
||
? `${rankPct}% · ${(nextRank.xpRequired - earnedXP).toLocaleString()} XP to ${nextRank.label}`
|
||
: "Maximum rank achieved ✨"}
|
||
</div>
|
||
<div className="qmlp-ladder-scroll" ref={ladderRef}>
|
||
<div className="qmlp-ladder-inner" style={{ width: totalW }}>
|
||
<div className="qmlp-ladder-baseline" />
|
||
<div
|
||
className="qmlp-ladder-progress"
|
||
style={{ width: animated ? shipXPos : 20 }}
|
||
/>
|
||
<div
|
||
className="qmlp-ship-wrap"
|
||
style={{ left: animated ? shipXPos : nodeX(0, N) }}
|
||
>
|
||
<span className="qmlp-ship">⛵</span>
|
||
<div className="qmlp-ship-tether" />
|
||
</div>
|
||
{ladder.map((r, i) => {
|
||
const state =
|
||
i < currentIdx
|
||
? "reached"
|
||
: i === currentIdx
|
||
? "current"
|
||
: "locked";
|
||
return (
|
||
<div key={r.id} className="qmlp-ladder-col">
|
||
<div className={`qmlp-ladder-node ${state}`}>{r.emoji}</div>
|
||
<div className="qmlp-ladder-label">
|
||
<span className={`qmlp-ladder-label-name ${state}`}>
|
||
{r.label}
|
||
</span>
|
||
<span className={`qmlp-ladder-label-xp ${state}`}>
|
||
{r.xpRequired === 0
|
||
? "Start"
|
||
: `${r.xpRequired.toLocaleString()} XP`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="qmlp-stats-grid">
|
||
<div className="qmlp-stat-tile gold">
|
||
<span className="qmlp-stat-icon">⚡</span>
|
||
<div className="qmlp-stat-value">{earnedXP.toLocaleString()}</div>
|
||
<div className="qmlp-stat-label">Total XP</div>
|
||
</div>
|
||
<div className="qmlp-stat-tile">
|
||
<span className="qmlp-stat-icon">🏝️</span>
|
||
<div className="qmlp-stat-value">
|
||
{totalDone}
|
||
<span
|
||
style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.28)" }}
|
||
>
|
||
/{totalNodes}
|
||
</span>
|
||
</div>
|
||
<div className="qmlp-stat-label">Islands</div>
|
||
</div>
|
||
{streak > 0 ? (
|
||
<div className="qmlp-stat-tile">
|
||
<span className="qmlp-stat-icon">🔥</span>
|
||
<div className="qmlp-stat-value">{streak}</div>
|
||
<div className="qmlp-stat-label">Day Streak</div>
|
||
</div>
|
||
) : (
|
||
<div className="qmlp-stat-tile">
|
||
<span className="qmlp-stat-icon">🎖️</span>
|
||
<div className="qmlp-stat-value">Lv {level}</div>
|
||
<div className="qmlp-stat-label">Level</div>
|
||
</div>
|
||
)}
|
||
<div className={`qmlp-stat-tile${claimable > 0 ? " gold" : ""}`}>
|
||
<span className="qmlp-stat-icon">📦</span>
|
||
<div className="qmlp-stat-value">{claimable}</div>
|
||
<div className="qmlp-stat-label">To Claim</div>
|
||
</div>
|
||
</div>
|
||
{activeNodes.length > 0 && (
|
||
<>
|
||
<div className="qmlp-section-title">Active Quests</div>
|
||
{activeNodes.slice(0, 5).map(({ node, arc: a }) => {
|
||
const t = getArcTheme(a);
|
||
const pct = Math.min(
|
||
100,
|
||
Math.round((node.current_value / node.req_target) * 100),
|
||
);
|
||
const isClaim = node.status === "CLAIMABLE";
|
||
return (
|
||
<div
|
||
key={node.node_id}
|
||
className={`qmlp-quest-card${isClaim ? " claimable" : ""}`}
|
||
style={{ "--qc-accent": t.accent } as React.CSSProperties}
|
||
>
|
||
<div className="qmlp-qc-top">
|
||
<div className="qmlp-qc-icon">
|
||
{isClaim ? "📦" : (REQ_EMOJI[node.req_type] ?? "🏝️")}
|
||
</div>
|
||
<div className="qmlp-qc-body">
|
||
<div className="qmlp-qc-arc">{a.name}</div>
|
||
<div className="qmlp-qc-name">{node.name ?? "—"}</div>
|
||
{isClaim ? (
|
||
<div className="qmlp-qc-status ready">
|
||
✨ Ready to claim!
|
||
</div>
|
||
) : (
|
||
<div className="qmlp-qc-status progress">
|
||
{node.current_value}/{node.req_target}{" "}
|
||
{REQ_LABEL[node.req_type] ?? node.req_type}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{!isClaim && (
|
||
<div className="qmlp-qc-prog-row">
|
||
<div className="qmlp-qc-track">
|
||
<div
|
||
className="qmlp-qc-fill"
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<span className="qmlp-qc-pct">{pct}%</span>
|
||
</div>
|
||
)}
|
||
{isClaim && (
|
||
<button
|
||
className="qmlp-claim-btn"
|
||
onClick={() => onClaim(node)}
|
||
>
|
||
⚓ Open Chest
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
<div className="qmlp-section-title">All Arcs</div>
|
||
<div className="qmlp-arc-list">
|
||
{sortedArcs.map((a) => {
|
||
const t = getArcTheme(a);
|
||
const done = a.nodes.filter((n) => n.status === "completed").length;
|
||
const pct = Math.round((done / Math.max(a.nodes.length, 1)) * 100);
|
||
const hasClaim = a.nodes.some((n) => n.status === "CLAIMABLE");
|
||
return (
|
||
<div
|
||
key={a.id}
|
||
className={`qmlp-arc-row${a.id === activeArcId ? " active" : ""}`}
|
||
style={{ "--arc-accent": t.accent } as React.CSSProperties}
|
||
onClick={() => {
|
||
onSelectArc(a.id);
|
||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||
}}
|
||
>
|
||
<span className="qmlp-arc-emoji">{t.emoji}</span>
|
||
<div className="qmlp-arc-info">
|
||
<div className="qmlp-arc-name">{a.name}</div>
|
||
<div className="qmlp-arc-track">
|
||
<div className="qmlp-arc-fill" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<div className="qmlp-arc-meta">
|
||
{done}/{a.nodes.length} islands · {pct}%
|
||
</div>
|
||
</div>
|
||
{hasClaim && <div className="qmlp-arc-dot" />}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||
export const QuestMap = () => {
|
||
const arcs = useQuestStore((s) => s.arcs);
|
||
const activeArcId = useQuestStore((s) => s.activeArcId);
|
||
const setActiveArc = useQuestStore((s) => s.setActiveArc);
|
||
const claimNode = useQuestStore((s) => s.claimNode);
|
||
const syncFromAPI = useQuestStore((s) => s.syncFromAPI);
|
||
const user = useAuthStore((s) => s.user);
|
||
const token = useAuthStore((s) => s.token);
|
||
|
||
const [loading, setLoading] = useState(true);
|
||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||
const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null);
|
||
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
|
||
null,
|
||
);
|
||
|
||
const [claimError, setClaimError] = useState<string | null>(null);
|
||
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
|
||
|
||
const mobileScrollRef = useRef<HTMLDivElement>(null);
|
||
const desktopScrollRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (arcs.length > 0 && !activeArcId) setActiveArc(arcs[0].id);
|
||
}, [arcs, activeArcId, setActiveArc]);
|
||
|
||
useEffect(() => {
|
||
if (!token) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
setLoading(true);
|
||
setFetchError(null);
|
||
const data = await api.fetchUserJourney(token);
|
||
if (!cancelled) syncFromAPI(data);
|
||
} catch (err) {
|
||
if (!cancelled)
|
||
setFetchError(
|
||
err instanceof Error ? err.message : "Failed to load quests",
|
||
);
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [token, syncFromAPI]);
|
||
|
||
const handleClaim = useCallback(
|
||
async (node: QuestNode) => {
|
||
if (!token) return;
|
||
setClaimingNode(node);
|
||
setClaimResult(null);
|
||
setClaimError(null);
|
||
|
||
try {
|
||
const result = await api.claimReward(token, node.node_id);
|
||
setClaimResult(result);
|
||
} catch (err) {
|
||
setClaimError(err instanceof Error ? err.message : "Claim failed");
|
||
}
|
||
},
|
||
[token],
|
||
);
|
||
|
||
const handleNodeTap = useCallback((node: QuestNode) => {
|
||
setSelectedNode(node);
|
||
}, []);
|
||
|
||
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||
|
||
const handleChestClose = useCallback(() => {
|
||
if (!claimingNode || !arc) return;
|
||
const titles = Array.isArray(claimResult?.title_unlocked)
|
||
? claimResult!.title_unlocked
|
||
: claimResult?.title_unlocked
|
||
? [claimResult.title_unlocked]
|
||
: [];
|
||
claimNode(
|
||
arc.id,
|
||
claimingNode.node_id,
|
||
claimResult?.xp_awarded ?? 0,
|
||
titles.map((t: any) => t.name),
|
||
);
|
||
setClaimingNode(null);
|
||
setClaimResult(null);
|
||
setClaimError(null);
|
||
}, [claimingNode, claimResult, arc, claimNode]);
|
||
|
||
// ── Early returns ─────────────────────────────────────────────────────────
|
||
if (loading) {
|
||
return (
|
||
<div
|
||
className="qm-screen"
|
||
style={{ alignItems: "center", justifyContent: "center" }}
|
||
>
|
||
<style>{STYLES}</style>
|
||
<div
|
||
style={{
|
||
textAlign: "center",
|
||
color: "rgba(255,255,255,0.5)",
|
||
fontFamily: "'Nunito',sans-serif",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: "2.5rem",
|
||
marginBottom: "1rem",
|
||
animation: "qmFabFloat 2s ease-in-out infinite",
|
||
}}
|
||
>
|
||
⛵
|
||
</div>
|
||
<p
|
||
style={{
|
||
fontSize: "0.85rem",
|
||
fontWeight: 800,
|
||
letterSpacing: "0.1em",
|
||
}}
|
||
>
|
||
CHARTING YOUR COURSE...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (fetchError || !arc) {
|
||
return (
|
||
<div
|
||
className="qm-screen"
|
||
style={{
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<style>
|
||
{STYLES}
|
||
{ERROR_STYLES}
|
||
</style>
|
||
|
||
{/* Deep-sea atmospheric background */}
|
||
<div className="qme-bg" />
|
||
<div className="qme-fog qme-fog1" />
|
||
<div className="qme-fog qme-fog2" />
|
||
|
||
{/* Floating wreckage debris */}
|
||
{[...Array(8)].map((_, i) => (
|
||
<div key={i} className={`qme-debris qme-debris-${i}`} />
|
||
))}
|
||
|
||
<div className="qme-stage">
|
||
{/* ── Shipwreck SVG illustration ── */}
|
||
<div className="qme-ship-wrap">
|
||
<svg
|
||
className="qme-ship-svg"
|
||
viewBox="0 0 240 180"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
{/* Ocean surface rings behind the wreck */}
|
||
<ellipse
|
||
cx="120"
|
||
cy="145"
|
||
rx="100"
|
||
ry="18"
|
||
fill="rgba(3,4,94,0.6)"
|
||
/>
|
||
<path
|
||
d="M20 145 Q45 132 70 145 Q95 158 120 145 Q145 132 170 145 Q195 158 220 145"
|
||
stroke="#0077b6"
|
||
strokeWidth="2"
|
||
fill="none"
|
||
opacity="0.5"
|
||
strokeDasharray="6 4"
|
||
>
|
||
<animate
|
||
attributeName="stroke-dashoffset"
|
||
values="0;20"
|
||
dur="2s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</path>
|
||
<path
|
||
d="M10 155 Q40 143 68 155 Q96 167 124 155 Q152 143 180 155 Q208 167 230 155"
|
||
stroke="#0096c7"
|
||
strokeWidth="1.5"
|
||
fill="none"
|
||
opacity="0.35"
|
||
strokeDasharray="5 5"
|
||
>
|
||
<animate
|
||
attributeName="stroke-dashoffset"
|
||
values="20;0"
|
||
dur="2.6s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</path>
|
||
|
||
{/* Broken mast — leaning left */}
|
||
<g className="qme-mast">
|
||
<line
|
||
x1="130"
|
||
y1="148"
|
||
x2="88"
|
||
y2="62"
|
||
stroke="#6b4226"
|
||
strokeWidth="5"
|
||
strokeLinecap="round"
|
||
/>
|
||
<line
|
||
x1="88"
|
||
y1="62"
|
||
x2="52"
|
||
y2="44"
|
||
stroke="#6b4226"
|
||
strokeWidth="3.5"
|
||
strokeLinecap="round"
|
||
opacity="0.7"
|
||
/>
|
||
{/* Shredded sail */}
|
||
<path
|
||
d="M88 62 Q74 75 64 92 Q78 88 94 78 Z"
|
||
fill="rgba(200,160,80,0.35)"
|
||
stroke="rgba(200,160,80,0.5)"
|
||
strokeWidth="1"
|
||
/>
|
||
<path
|
||
d="M88 62 Q98 72 104 84 Q96 80 88 62 Z"
|
||
fill="rgba(200,160,80,0.2)"
|
||
stroke="rgba(200,160,80,0.3)"
|
||
strokeWidth="1"
|
||
/>
|
||
{/* Torn pirate flag */}
|
||
<path d="M52 44 L72 51 L58 61 Z" fill="rgba(239,68,68,0.7)" />
|
||
<path
|
||
d="M58 61 Q65 57 72 51"
|
||
stroke="rgba(239,68,68,0.5)"
|
||
strokeWidth="1"
|
||
fill="none"
|
||
strokeDasharray="3 2"
|
||
>
|
||
<animate
|
||
attributeName="stroke-dashoffset"
|
||
values="0;10"
|
||
dur="1.2s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</path>
|
||
</g>
|
||
|
||
{/* Hull — cracked, half-submerged */}
|
||
<g className="qme-hull">
|
||
<path
|
||
d="M58 130 Q72 110 100 108 Q128 106 152 112 Q172 116 174 130 Q160 148 120 152 Q80 156 58 130 Z"
|
||
fill="#3d1c0c"
|
||
stroke="#6b4226"
|
||
strokeWidth="2"
|
||
/>
|
||
{/* Crack lines */}
|
||
<path
|
||
d="M100 108 L96 128 L104 140"
|
||
stroke="rgba(0,0,0,0.6)"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
fill="none"
|
||
/>
|
||
<path
|
||
d="M138 110 L142 132"
|
||
stroke="rgba(0,0,0,0.5)"
|
||
strokeWidth="1"
|
||
strokeLinecap="round"
|
||
fill="none"
|
||
/>
|
||
{/* Porthole */}
|
||
<circle
|
||
cx="118"
|
||
cy="128"
|
||
r="7"
|
||
fill="#1a0900"
|
||
stroke="#6b4226"
|
||
strokeWidth="1.5"
|
||
/>
|
||
<circle
|
||
cx="118"
|
||
cy="128"
|
||
r="4"
|
||
fill="rgba(251,191,36,0.06)"
|
||
stroke="rgba(251,191,36,0.15)"
|
||
strokeWidth="1"
|
||
/>
|
||
{/* Water-line sheen */}
|
||
<path
|
||
d="M70 140 Q95 133 120 135 Q145 133 168 138"
|
||
stroke="rgba(0,180,216,0.3)"
|
||
strokeWidth="1"
|
||
fill="none"
|
||
/>
|
||
</g>
|
||
|
||
{/* Barnacles */}
|
||
<circle cx="85" cy="133" r="2" fill="rgba(100,200,100,0.25)" />
|
||
<circle cx="92" cy="140" r="1.5" fill="rgba(100,200,100,0.2)" />
|
||
<circle cx="155" cy="130" r="2" fill="rgba(100,200,100,0.25)" />
|
||
|
||
{/* Floating planks */}
|
||
<rect
|
||
x="40"
|
||
y="138"
|
||
width="14"
|
||
height="5"
|
||
rx="2"
|
||
fill="#4a2c10"
|
||
opacity="0.8"
|
||
>
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="translate"
|
||
values="0,0;0,-3;0,0"
|
||
dur="3.2s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</rect>
|
||
<rect
|
||
x="185"
|
||
y="141"
|
||
width="10"
|
||
height="4"
|
||
rx="1.5"
|
||
fill="#4a2c10"
|
||
opacity="0.6"
|
||
>
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="translate"
|
||
values="0,0;0,-2;0,0"
|
||
dur="2.7s"
|
||
begin="0.5s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</rect>
|
||
|
||
{/* Half-submerged treasure chest */}
|
||
<g opacity="0.9">
|
||
<rect
|
||
x="106"
|
||
y="148"
|
||
width="28"
|
||
height="18"
|
||
rx="3"
|
||
fill="#5c3d1a"
|
||
stroke="#fbbf24"
|
||
strokeWidth="1.2"
|
||
/>
|
||
<rect
|
||
x="106"
|
||
y="148"
|
||
width="28"
|
||
height="8"
|
||
rx="3"
|
||
fill="#7a5228"
|
||
stroke="#fbbf24"
|
||
strokeWidth="1.2"
|
||
/>
|
||
<rect
|
||
x="117"
|
||
y="153"
|
||
width="6"
|
||
height="5"
|
||
rx="1"
|
||
fill="#fbbf24"
|
||
opacity="0.8"
|
||
/>
|
||
<circle cx="120" cy="152" r="1" fill="white" opacity="0.7">
|
||
<animate
|
||
attributeName="opacity"
|
||
values="0.7;0.1;0.7"
|
||
dur="2s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</circle>
|
||
</g>
|
||
|
||
{/* Rising bubbles */}
|
||
<circle
|
||
cx="108"
|
||
cy="130"
|
||
r="2"
|
||
fill="none"
|
||
stroke="rgba(0,180,216,0.5)"
|
||
strokeWidth="1"
|
||
>
|
||
<animate
|
||
attributeName="cy"
|
||
values="130;90"
|
||
dur="3s"
|
||
repeatCount="indefinite"
|
||
begin="0s"
|
||
/>
|
||
<animate
|
||
attributeName="opacity"
|
||
values="0.6;0"
|
||
dur="3s"
|
||
repeatCount="indefinite"
|
||
begin="0s"
|
||
/>
|
||
</circle>
|
||
<circle
|
||
cx="135"
|
||
cy="125"
|
||
r="1.5"
|
||
fill="none"
|
||
stroke="rgba(0,180,216,0.4)"
|
||
strokeWidth="1"
|
||
>
|
||
<animate
|
||
attributeName="cy"
|
||
values="125;85"
|
||
dur="2.5s"
|
||
repeatCount="indefinite"
|
||
begin="0.8s"
|
||
/>
|
||
<animate
|
||
attributeName="opacity"
|
||
values="0.5;0"
|
||
dur="2.5s"
|
||
repeatCount="indefinite"
|
||
begin="0.8s"
|
||
/>
|
||
</circle>
|
||
<circle
|
||
cx="122"
|
||
cy="132"
|
||
r="1"
|
||
fill="none"
|
||
stroke="rgba(0,180,216,0.35)"
|
||
strokeWidth="0.8"
|
||
>
|
||
<animate
|
||
attributeName="cy"
|
||
values="132;100"
|
||
dur="2.1s"
|
||
repeatCount="indefinite"
|
||
begin="1.4s"
|
||
/>
|
||
<animate
|
||
attributeName="opacity"
|
||
values="0.4;0"
|
||
dur="2.1s"
|
||
repeatCount="indefinite"
|
||
begin="1.4s"
|
||
/>
|
||
</circle>
|
||
</svg>
|
||
</div>
|
||
|
||
{/* Copy & actions */}
|
||
<div className="qme-content">
|
||
<div className="qme-eyebrow">⚓ SHIPWRECKED</div>
|
||
<h1 className="qme-title">Lost at Sea</h1>
|
||
<p className="qme-subtitle">
|
||
{fetchError
|
||
? "The charts couldn't be retrieved from the depths."
|
||
: "Your quest map has sunk without a trace."}
|
||
</p>
|
||
<div className="qme-error-badge">
|
||
<span className="qme-error-dot" />
|
||
<span className="qme-error-text">
|
||
{fetchError ?? "No quest data found"}
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="qme-retry-btn"
|
||
onClick={() => window.location.reload()}
|
||
>
|
||
<span className="qme-btn-wake" />
|
||
<span className="qme-btn-label">⛵ Set Sail Again</span>
|
||
</button>
|
||
<p className="qme-hint">
|
||
Your progress and treasures are safely stored in Davy Jones'
|
||
vault.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const theme = getArcTheme(arc);
|
||
const sorted = [...arc.nodes].sort(
|
||
(a, b) => a.sequence_order - b.sequence_order,
|
||
);
|
||
|
||
// ── Generate random positions (deterministic per arc) ─────────────────────
|
||
const positions = generateIslandPositions(sorted.length, arc.id);
|
||
|
||
const sortedArcs = [...arcs].sort(
|
||
(a, b) => a.sequence_order - b.sequence_order,
|
||
);
|
||
|
||
// Focus camera on first active/claimable island
|
||
const focusIdx = (() => {
|
||
const i = sorted.findIndex(
|
||
(n) => n.status === "ACTIVE" || n.status === "CLAIMABLE",
|
||
);
|
||
return i >= 0 ? i : 0;
|
||
})();
|
||
const nodeCount = sorted.length;
|
||
const focusX3 = ((positions[focusIdx]?.x ?? VW / 2) / VW) * 9 - 4.5;
|
||
const focusZ3 = (focusIdx / Math.max(nodeCount - 1, 1)) * (nodeCount * 2.4);
|
||
const initialTarget: [number, number, number] = [focusX3, 0, focusZ3];
|
||
|
||
const modalOpen = !!(selectedNode || claimingNode);
|
||
|
||
return (
|
||
<div className="qm-screen">
|
||
<style>{STYLES}</style>
|
||
|
||
{/* ══ MOBILE ══ */}
|
||
<div className="qm-header">
|
||
<InfoHeader mode="QUEST_EXTENDED" />
|
||
<div className="qm-arc-tabs">
|
||
{sortedArcs.map((a) => {
|
||
const t = getArcTheme(a);
|
||
return (
|
||
<button
|
||
key={a.id}
|
||
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
|
||
style={{ "--arc-accent": t.accent } as React.CSSProperties}
|
||
onClick={() => {
|
||
setActiveArc(a.id);
|
||
mobileScrollRef.current?.scrollTo({
|
||
top: 0,
|
||
behavior: "smooth",
|
||
});
|
||
}}
|
||
>
|
||
{t.emoji} {a.name}
|
||
{a.nodes.some((n) => n.status === "CLAIMABLE") && (
|
||
<span className="qm-tab-dot" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="qm-sea-scroll qm-mobile-sea" ref={mobileScrollRef}>
|
||
<MapContent
|
||
arc={arc}
|
||
theme={theme}
|
||
positions={positions}
|
||
onNodeTap={handleNodeTap}
|
||
onClaim={handleClaim}
|
||
initialTarget={initialTarget}
|
||
modalOpen={modalOpen}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="qm-fab"
|
||
onClick={() =>
|
||
mobileScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })
|
||
}
|
||
>
|
||
🏴☠️
|
||
</div>
|
||
|
||
{/* ══ DESKTOP ══ */}
|
||
<LeftPanel
|
||
arcs={arcs}
|
||
activeArcId={activeArcId ?? arc.id}
|
||
onSelectArc={setActiveArc}
|
||
scrollRef={desktopScrollRef}
|
||
user={user}
|
||
onClaim={handleClaim}
|
||
/>
|
||
<div className="qm-right-panel">
|
||
<div className="qm-desktop-arc-tabs">
|
||
{sortedArcs.map((a) => {
|
||
const t = getArcTheme(a);
|
||
return (
|
||
<button
|
||
key={a.id}
|
||
className={`qm-desktop-arc-tab${activeArcId === a.id ? " active" : ""}`}
|
||
style={{ "--arc-accent": t.accent } as React.CSSProperties}
|
||
onClick={() => {
|
||
setActiveArc(a.id);
|
||
desktopScrollRef.current?.scrollTo({
|
||
top: 0,
|
||
behavior: "smooth",
|
||
});
|
||
}}
|
||
>
|
||
{t.emoji} {a.name}
|
||
{a.nodes.some((n) => n.status === "CLAIMABLE") && (
|
||
<span className="qm-desktop-tab-dot" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="qm-right-sea-scroll" ref={desktopScrollRef}>
|
||
<MapContent
|
||
arc={arc}
|
||
theme={theme}
|
||
positions={positions}
|
||
onNodeTap={handleNodeTap}
|
||
onClaim={handleClaim}
|
||
initialTarget={initialTarget}
|
||
modalOpen={modalOpen}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedNode && (
|
||
<QuestNodeModal
|
||
node={selectedNode}
|
||
arc={arc}
|
||
arcAccent={theme.accent}
|
||
arcDark={theme.accentDark}
|
||
arcId={arc.id}
|
||
nodeIndex={selectedNode.sequence_order}
|
||
onClose={() => setSelectedNode(null)}
|
||
onClaim={() => {
|
||
setSelectedNode(null);
|
||
handleClaim(selectedNode);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{claimingNode && (
|
||
<ChestOpenModal
|
||
node={claimingNode}
|
||
claimResult={claimResult}
|
||
onClose={handleChestClose}
|
||
/>
|
||
)}
|
||
|
||
{claimError && (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
bottom: "calc(2rem + env(safe-area-inset-bottom))",
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
zIndex: 100,
|
||
background: "#7f1d1d",
|
||
border: "1px solid #ef4444",
|
||
borderRadius: "12px",
|
||
padding: "0.6rem 1.1rem",
|
||
color: "white",
|
||
fontFamily: "'Nunito',sans-serif",
|
||
fontSize: "0.78rem",
|
||
fontWeight: 800,
|
||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
⚠️ {claimError} — your progress is saved
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|