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(); 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 = { questions: "❓", accuracy: "🎯", streak: "🔥", sessions: "📚", topics: "🗺️", xp: "⚡", leaderboard: "🏆", }; const REQ_LABEL: Record = { 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(null); const glowRef = useRef(null); const shipRef = useRef(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 ( {/* Glow underlayer tube */} {(isDone || isNext || isActive) && ( )} {/* Dashed route line */} { // 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()} > {/* Travelling ship on the active leg */} {isActive && !isDone && ( {/* Gold wake glow disk */} )} ); }; // ── RoutePaths3D — all route segments, lives inside ───────────────── 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 ( ); })} ); }; // ─── 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(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 ( ); }; // ── 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 ( <> {/* 3D sea routes — live in world space, respect camera */} {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 ( ); })} ); }; // ── 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 (
{/* Zoom buttons */}
{}} >
); }; // ─── Left Panel ─────────────────────────────────────────────────────────────── const LeftPanel = ({ arcs, activeArcId, onSelectArc, scrollRef, user, onClaim, }: { arcs: QuestArc[]; activeArcId: string; onSelectArc: (id: string) => void; scrollRef: React.RefObject; 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(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 (
🏴‍☠️ Quest Map
Track your voyage
⚓ Crew Rank {earnedXP.toLocaleString()} XP
{current.emoji} {current.label}
{nextRank ? `${rankPct}% · ${(nextRank.xpRequired - earnedXP).toLocaleString()} XP to ${nextRank.label}` : "Maximum rank achieved ✨"}
{ladder.map((r, i) => { const state = i < currentIdx ? "reached" : i === currentIdx ? "current" : "locked"; return (
{r.emoji}
{r.label} {r.xpRequired === 0 ? "Start" : `${r.xpRequired.toLocaleString()} XP`}
); })}
{earnedXP.toLocaleString()}
Total XP
🏝️
{totalDone} /{totalNodes}
Islands
{streak > 0 ? (
🔥
{streak}
Day Streak
) : (
🎖️
Lv {level}
Level
)}
0 ? " gold" : ""}`}> 📦
{claimable}
To Claim
{activeNodes.length > 0 && ( <>
Active Quests
{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 (
{isClaim ? "📦" : (REQ_EMOJI[node.req_type] ?? "🏝️")}
{a.name}
{node.name ?? "—"}
{isClaim ? (
✨ Ready to claim!
) : (
{node.current_value}/{node.req_target}{" "} {REQ_LABEL[node.req_type] ?? node.req_type}
)}
{!isClaim && (
{pct}%
)} {isClaim && ( )}
); })} )}
All Arcs
{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 (
{ onSelectArc(a.id); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }} > {t.emoji}
{a.name}
{done}/{a.nodes.length} islands · {pct}%
{hasClaim &&
}
); })}
); }; // ─── 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(null); const [claimingNode, setClaimingNode] = useState(null); const [claimResult, setClaimResult] = useState( null, ); const [claimError, setClaimError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const mobileScrollRef = useRef(null); const desktopScrollRef = useRef(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 (

CHARTING YOUR COURSE...

); } if (fetchError || !arc) { return (
{/* Deep-sea atmospheric background */}
{/* Floating wreckage debris */} {[...Array(8)].map((_, i) => (
))}
{/* ── Shipwreck SVG illustration ── */}
{/* Ocean surface rings behind the wreck */} {/* Broken mast — leaning left */} {/* Shredded sail */} {/* Torn pirate flag */} {/* Hull — cracked, half-submerged */} {/* Crack lines */} {/* Porthole */} {/* Water-line sheen */} {/* Barnacles */} {/* Floating planks */} {/* Half-submerged treasure chest */} {/* Rising bubbles */}
{/* Copy & actions */}
⚓ SHIPWRECKED

Lost at Sea

{fetchError ? "The charts couldn't be retrieved from the depths." : "Your quest map has sunk without a trace."}

{fetchError ?? "No quest data found"}

Your progress and treasures are safely stored in Davy Jones' vault.

); } 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 (
{/* ══ MOBILE ══ */}
{sortedArcs.map((a) => { const t = getArcTheme(a); return ( ); })}
mobileScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }) } > 🏴‍☠️
{/* ══ DESKTOP ══ */}
{sortedArcs.map((a) => { const t = getArcTheme(a); return ( ); })}
{selectedNode && ( setSelectedNode(null)} onClaim={() => { setSelectedNode(null); handleClaim(selectedNode); }} /> )} {claimingNode && ( )} {claimError && (
⚠️ {claimError} — your progress is saved
)}
); };