Files
edbridge-scholars/src/pages/student/QuestMap.tsx
2026-03-12 02:39:34 +06:00

2219 lines
83 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};