fix(api): fix api integration for quest map and adjacent components
This commit is contained in:
@ -1,6 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Lock } from "lucide-react";
|
||||
import type { QuestNode } from "../types/quest";
|
||||
import type { QuestNode, QuestArc } from "../types/quest";
|
||||
// Re-use the same theme generator as QuestMap so island colours are consistent
|
||||
import { generateArcTheme } from "../pages/student/QuestMap";
|
||||
|
||||
// ─── Requirement helpers (mirrors QuestMap / InfoHeader) ──────────────────────
|
||||
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",
|
||||
};
|
||||
|
||||
const reqIcon = (type: string): string =>
|
||||
({
|
||||
questions: "❓",
|
||||
accuracy: "🎯",
|
||||
streak: "🔥",
|
||||
sessions: "📚",
|
||||
topics: "🗺️",
|
||||
xp: "⚡",
|
||||
leaderboard: "🏆",
|
||||
})[type] ?? "⭐";
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
const STYLES = `
|
||||
@ -30,11 +54,9 @@ const STYLES = `
|
||||
}
|
||||
@keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} }
|
||||
|
||||
/* Handle */
|
||||
.qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; }
|
||||
.qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); }
|
||||
|
||||
/* Close btn */
|
||||
.qnm-close {
|
||||
position:absolute; top:0.9rem; right:1.1rem; z-index:10;
|
||||
width:30px; height:30px; border-radius:50%;
|
||||
@ -50,8 +72,6 @@ const STYLES = `
|
||||
height: 200px; overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%);
|
||||
}
|
||||
|
||||
/* Sea waves */
|
||||
.qnm-sea {
|
||||
position:absolute; bottom:0; left:0; right:0; height:52px;
|
||||
background: var(--sea-col); overflow:hidden;
|
||||
@ -69,25 +89,19 @@ const STYLES = `
|
||||
50% { transform: translateX(15%) scaleY(1.08);}
|
||||
100%{ transform: translateX(0) scaleY(1); }
|
||||
}
|
||||
|
||||
/* Floating clouds */
|
||||
.qnm-cloud {
|
||||
position:absolute; border-radius:50px;
|
||||
background: rgba(255,255,255,0.18);
|
||||
filter: blur(4px);
|
||||
background: rgba(255,255,255,0.18); filter: blur(4px);
|
||||
animation: qnmDrift var(--cdur,18s) linear infinite;
|
||||
}
|
||||
@keyframes qnmDrift {
|
||||
0% { transform: translateX(-120px); opacity:0; }
|
||||
0% { transform: translateX(-120px); opacity:0; }
|
||||
10% { opacity:1; }
|
||||
90% { opacity:1; }
|
||||
100%{ transform: translateX(calc(100vw + 120px)); opacity:0; }
|
||||
}
|
||||
|
||||
/* ── The 3D island container ── */
|
||||
.qnm-island-3d-wrap {
|
||||
position: absolute;
|
||||
left: 50%; bottom: 40px;
|
||||
position: absolute; left: 50%; bottom: 40px;
|
||||
transform: translateX(-50%);
|
||||
perspective: 420px;
|
||||
width: 220px; height: 140px;
|
||||
@ -102,16 +116,12 @@ const STYLES = `
|
||||
0% { transform: rotateX(22deg) rotateY(0deg); }
|
||||
100% { transform: rotateX(22deg) rotateY(360deg); }
|
||||
}
|
||||
|
||||
/* Island layers — stacked in 3D */
|
||||
.qnm-il { /* island layer base class */
|
||||
.qnm-il {
|
||||
position: absolute; left: 50%; bottom: 0;
|
||||
transform-origin: bottom center;
|
||||
border-radius: 50%;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Water base disc */
|
||||
.qnm-il-water {
|
||||
width: 200px; height: 44px; margin-left: -100px;
|
||||
background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col));
|
||||
@ -120,12 +130,7 @@ const STYLES = `
|
||||
box-shadow: 0 0 40px var(--sea-col);
|
||||
animation: qnmWaterShimmer 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes qnmWaterShimmer {
|
||||
0%,100%{ opacity:1; }
|
||||
50% { opacity:0.82; }
|
||||
}
|
||||
|
||||
/* Ripple rings on water */
|
||||
@keyframes qnmWaterShimmer { 0%,100%{ opacity:1; } 50%{ opacity:0.82; } }
|
||||
.qnm-ripple {
|
||||
position:absolute; left:50%; top:50%;
|
||||
border-radius:50%; border:1.5px solid rgba(255,255,255,0.25);
|
||||
@ -136,8 +141,6 @@ const STYLES = `
|
||||
0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; }
|
||||
100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; }
|
||||
}
|
||||
|
||||
/* Island ground */
|
||||
.qnm-il-ground {
|
||||
width: 160px; height: 36px; margin-left: -80px;
|
||||
background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo));
|
||||
@ -145,8 +148,6 @@ const STYLES = `
|
||||
transform: translateZ(14px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
/* Island side face — gives the 3D depth illusion */
|
||||
.qnm-il-side {
|
||||
width: 158px; height: 22px; margin-left: -79px;
|
||||
bottom: -12px;
|
||||
@ -154,8 +155,6 @@ const STYLES = `
|
||||
clip-path: ellipse(79px 100% at 50% 0%);
|
||||
transform: translateZ(8px) rotateX(-8deg);
|
||||
}
|
||||
|
||||
/* Peak */
|
||||
.qnm-il-peak {
|
||||
width: 80px; height: 60px; margin-left: -40px;
|
||||
bottom: 26px;
|
||||
@ -169,28 +168,18 @@ const STYLES = `
|
||||
0%,100%{ transform: translateZ(26px) translateY(0); }
|
||||
50% { transform: translateZ(26px) translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Floating decoration layer (trees, cactus, cloud orb, etc.) */
|
||||
.qnm-il-deco {
|
||||
position: absolute; bottom: 56px; left: 50%;
|
||||
transform: translateZ(42px);
|
||||
animation: qnmDecoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes qnmDecoFloat {
|
||||
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
|
||||
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
|
||||
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
|
||||
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
|
||||
}
|
||||
.qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); }
|
||||
|
||||
/* Flag pole on active */
|
||||
.qnm-il-flag {
|
||||
position:absolute; bottom:56px; left:50%;
|
||||
transform: translateZ(50px) translateX(12px);
|
||||
}
|
||||
.qnm-flag-pole {
|
||||
width:2px; height:26px; background:#7c4a1e;
|
||||
border-radius:2px;
|
||||
}
|
||||
.qnm-il-flag { position:absolute; bottom:56px; left:50%; transform: translateZ(50px) translateX(12px); }
|
||||
.qnm-flag-pole { width:2px; height:26px; background:#7c4a1e; border-radius:2px; }
|
||||
.qnm-flag-cloth {
|
||||
position:absolute; top:2px; left:2px;
|
||||
width:16px; height:11px;
|
||||
@ -198,19 +187,14 @@ const STYLES = `
|
||||
animation: qnmFlagWave 1.2s ease-in-out infinite;
|
||||
transform-origin:left center;
|
||||
}
|
||||
@keyframes qnmFlagWave {
|
||||
0%,100%{ transform:skewY(0deg); }
|
||||
50% { transform:skewY(-10deg); }
|
||||
}
|
||||
|
||||
/* Stars / sparkles above completed island */
|
||||
@keyframes qnmFlagWave { 0%,100%{ transform:skewY(0deg); } 50%{ transform:skewY(-10deg); } }
|
||||
.qnm-star {
|
||||
position:absolute; font-size:1rem;
|
||||
animation: qnmStarPop var(--sdur,2s) ease-in-out infinite;
|
||||
animation-delay: var(--sdel,0s);
|
||||
}
|
||||
@keyframes qnmStarPop {
|
||||
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
|
||||
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
|
||||
50% { transform:scale(1.4) translateY(-8px); opacity:1; }
|
||||
}
|
||||
|
||||
@ -221,8 +205,6 @@ const STYLES = `
|
||||
padding:1.1rem 1.25rem 0.5rem;
|
||||
}
|
||||
.qnm-body::-webkit-scrollbar { display:none; }
|
||||
|
||||
/* Title block */
|
||||
.qnm-title-block { position:relative; }
|
||||
.qnm-arc-tag {
|
||||
display:inline-flex; align-items:center; gap:0.3rem;
|
||||
@ -240,8 +222,6 @@ const STYLES = `
|
||||
font-family:'Nunito Sans',sans-serif;
|
||||
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38);
|
||||
}
|
||||
|
||||
/* Flavour quote */
|
||||
.qnm-flavour {
|
||||
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
||||
border-left:3px solid var(--ac);
|
||||
@ -253,8 +233,6 @@ const STYLES = `
|
||||
font-size:0.82rem; color:rgba(255,255,255,0.55);
|
||||
font-style:italic; line-height:1.6;
|
||||
}
|
||||
|
||||
/* Objective card */
|
||||
.qnm-obj-card {
|
||||
background:rgba(255,255,255,0.04);
|
||||
border:1px solid rgba(255,255,255,0.08);
|
||||
@ -271,9 +249,7 @@ const STYLES = `
|
||||
font-family:'Nunito',sans-serif;
|
||||
font-size:0.78rem; font-weight:900; color:var(--ac);
|
||||
}
|
||||
.qnm-obj-row {
|
||||
display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem;
|
||||
}
|
||||
.qnm-obj-row { display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; }
|
||||
.qnm-obj-icon {
|
||||
width:38px; height:38px; border-radius:12px; flex-shrink:0;
|
||||
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08);
|
||||
@ -287,8 +263,6 @@ const STYLES = `
|
||||
font-family:'Nunito Sans',sans-serif;
|
||||
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.qnm-bar-track {
|
||||
height:9px; background:rgba(255,255,255,0.07);
|
||||
border-radius:100px; overflow:hidden; margin-bottom:0.3rem;
|
||||
@ -305,21 +279,16 @@ const STYLES = `
|
||||
font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28);
|
||||
}
|
||||
.qnm-bar-nums span:first-child { color:var(--ac); }
|
||||
|
||||
/* ── HOW TO COMPLETE section ── */
|
||||
.qnm-howto-label {
|
||||
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
|
||||
text-transform:uppercase; color:rgba(255,255,255,0.3);
|
||||
margin-bottom:0.55rem; margin-top:0.3rem;
|
||||
}
|
||||
.qnm-howto-badges {
|
||||
display:flex; flex-wrap:wrap; gap:0.4rem;
|
||||
}
|
||||
.qnm-howto-badges { display:flex; flex-wrap:wrap; gap:0.4rem; }
|
||||
.qnm-howto-badge {
|
||||
display:flex; align-items:center; gap:0.3rem;
|
||||
padding:0.38rem 0.75rem;
|
||||
background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(255,255,255,0.1);
|
||||
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);
|
||||
border-radius:100px;
|
||||
font-family:'Nunito',sans-serif;
|
||||
font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7);
|
||||
@ -332,19 +301,14 @@ const STYLES = `
|
||||
to { opacity:1; transform:scale(1) translateY(0); }
|
||||
}
|
||||
.qnm-howto-badge:hover {
|
||||
background:rgba(255,255,255,0.1);
|
||||
border-color:rgba(255,255,255,0.2);
|
||||
color:white;
|
||||
transform:translateY(-1px);
|
||||
background:rgba(255,255,255,0.1); border-color:rgba(255,255,255,0.2);
|
||||
color:white; transform:translateY(-1px);
|
||||
}
|
||||
/* Highlight badge = accent coloured */
|
||||
.qnm-howto-badge.hi {
|
||||
background:color-mix(in srgb, var(--ac) 18%, transparent);
|
||||
border-color:color-mix(in srgb, var(--ac) 45%, transparent);
|
||||
color:var(--ac);
|
||||
}
|
||||
|
||||
/* Locked banner */
|
||||
.qnm-locked-banner {
|
||||
display:flex; align-items:center; gap:0.7rem;
|
||||
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
|
||||
@ -362,11 +326,8 @@ const STYLES = `
|
||||
font-family:'Nunito Sans',sans-serif;
|
||||
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem;
|
||||
}
|
||||
|
||||
/* Reward card */
|
||||
.qnm-reward-card {
|
||||
background:rgba(251,191,36,0.07);
|
||||
border:1px solid rgba(251,191,36,0.22);
|
||||
background:rgba(251,191,36,0.07); border:1px solid rgba(251,191,36,0.22);
|
||||
border-radius:18px; padding:0.9rem 1rem;
|
||||
}
|
||||
.qnm-reward-label {
|
||||
@ -409,71 +370,12 @@ const STYLES = `
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Per-arc terrain themes ───────────────────────────────────────────────────
|
||||
interface Terrain {
|
||||
skyTop: string;
|
||||
skyBot: string;
|
||||
seaCol: string;
|
||||
seaHi: string;
|
||||
terrHi: string;
|
||||
terrMid: string;
|
||||
terrLo: string;
|
||||
peakHi: string;
|
||||
peakMid: string;
|
||||
peakLo: string;
|
||||
decos: string[];
|
||||
}
|
||||
|
||||
const TERRAIN: Record<string, Terrain> = {
|
||||
east_blue: {
|
||||
skyTop: "#0a1628",
|
||||
skyBot: "#0d2240",
|
||||
seaCol: "#0a3d5c",
|
||||
seaHi: "#1a6a8a",
|
||||
terrHi: "#5eead4",
|
||||
terrMid: "#0d9488",
|
||||
terrLo: "#0f5c55",
|
||||
peakHi: "#a7f3d0",
|
||||
peakMid: "#34d399",
|
||||
peakLo: "#065f46",
|
||||
decos: ["🌴", "🌿"],
|
||||
},
|
||||
alabasta: {
|
||||
skyTop: "#1c0a00",
|
||||
skyBot: "#3d1a00",
|
||||
seaCol: "#7c3a00",
|
||||
seaHi: "#c26010",
|
||||
terrHi: "#fde68a",
|
||||
terrMid: "#d97706",
|
||||
terrLo: "#78350f",
|
||||
peakHi: "#fef3c7",
|
||||
peakMid: "#fbbf24",
|
||||
peakLo: "#92400e",
|
||||
decos: ["🌵", "🏺"],
|
||||
},
|
||||
skypiea: {
|
||||
skyTop: "#1a0033",
|
||||
skyBot: "#2e0050",
|
||||
seaCol: "#4c1d95",
|
||||
seaHi: "#7c3aed",
|
||||
terrHi: "#e9d5ff",
|
||||
terrMid: "#a855f7",
|
||||
terrLo: "#581c87",
|
||||
peakHi: "#f5d0fe",
|
||||
peakMid: "#d946ef",
|
||||
peakLo: "#701a75",
|
||||
decos: ["☁️", "✨"],
|
||||
},
|
||||
};
|
||||
const DEFAULT_TERRAIN = TERRAIN.east_blue;
|
||||
|
||||
// ─── Per-requirement how-to badges ───────────────────────────────────────────
|
||||
// ─── How-to badges ────────────────────────────────────────────────────────────
|
||||
interface Badge {
|
||||
emoji: string;
|
||||
label: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
|
||||
questions: {
|
||||
title: "How to complete this",
|
||||
@ -540,12 +442,7 @@ const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Island shape configs (mirrors the 6 clip-path shapes in QuestMap) ────────
|
||||
// groundClip = clip-path for the flat top disc of the island
|
||||
// peakClip = clip-path for the hill/feature rising above it
|
||||
// groundW/H = pixel size of the ground layer
|
||||
// peakW/H = pixel size of the peak layer
|
||||
// sideClip = clip-path for the side-face depth layer
|
||||
// ─── Island shape configs (mirrors QuestMap SHAPES[0..5]) ─────────────────────
|
||||
interface ShapeConfig {
|
||||
groundClip: string;
|
||||
peakClip: string;
|
||||
@ -554,12 +451,9 @@ interface ShapeConfig {
|
||||
groundH: number;
|
||||
peakW: number;
|
||||
peakH: number;
|
||||
peakBottom: number; // translateZ bottom offset in px
|
||||
peakBottom: number;
|
||||
}
|
||||
|
||||
// These correspond 1-to-1 with SHAPES[0..5] in QuestMap.tsx
|
||||
const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
// 0: fat round atoll
|
||||
{
|
||||
groundClip: "ellipse(50% 50% at 50% 50%)",
|
||||
peakClip: "ellipse(50% 50% at 50% 55%)",
|
||||
@ -570,7 +464,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
peakH: 38,
|
||||
peakBottom: 26,
|
||||
},
|
||||
// 1: tall mountain — narrow diamond ground, sharp triangular peak
|
||||
{
|
||||
groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)",
|
||||
peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)",
|
||||
@ -581,7 +474,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
peakH: 72,
|
||||
peakBottom: 24,
|
||||
},
|
||||
// 2: wide flat shoal — extra-wide squashed ellipse, low dome
|
||||
{
|
||||
groundClip: "ellipse(50% 40% at 50% 58%)",
|
||||
peakClip: "ellipse(50% 38% at 50% 60%)",
|
||||
@ -592,7 +484,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
peakH: 28,
|
||||
peakBottom: 22,
|
||||
},
|
||||
// 3: jagged rocky reef — star-burst polygon
|
||||
{
|
||||
groundClip:
|
||||
"polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)",
|
||||
@ -605,7 +496,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
peakH: 66,
|
||||
peakBottom: 24,
|
||||
},
|
||||
// 4: crescent — lopsided asymmetric bean
|
||||
{
|
||||
groundClip:
|
||||
"path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')",
|
||||
@ -617,7 +507,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
peakH: 58,
|
||||
peakBottom: 22,
|
||||
},
|
||||
// 5: teardrop/pear — narrow top, wide rounded base
|
||||
{
|
||||
groundClip:
|
||||
"path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')",
|
||||
@ -632,29 +521,104 @@ const ISLAND_SHAPES: ShapeConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const reqIcon = (type: string): string =>
|
||||
({
|
||||
questions: "❓",
|
||||
accuracy: "🎯",
|
||||
streak: "🔥",
|
||||
sessions: "📚",
|
||||
topics: "🗺️",
|
||||
xp: "⚡",
|
||||
leaderboard: "🏆",
|
||||
})[type] ?? "⭐";
|
||||
// ─── Terrain type (mirrors ArcTheme.terrain from QuestMap) ────────────────────
|
||||
interface StageTerrain {
|
||||
skyTop: string;
|
||||
skyBot: string;
|
||||
seaCol: string;
|
||||
seaHi: string;
|
||||
terrHi: string;
|
||||
terrMid: string;
|
||||
terrLo: string;
|
||||
peakHi: string;
|
||||
peakMid: string;
|
||||
peakLo: string;
|
||||
decos: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the ArcTheme colours produced by generateArcTheme into the
|
||||
* StageTerrain shape the 3D stage needs. For the three known arcs we keep
|
||||
* hand-tuned sky/sea values; for unknown arcs we derive them from the theme.
|
||||
*/
|
||||
const KNOWN_STAGE_TERRAIN: Record<string, StageTerrain> = {
|
||||
east_blue: {
|
||||
skyTop: "#0a1628",
|
||||
skyBot: "#0d2240",
|
||||
seaCol: "#0a3d5c",
|
||||
seaHi: "#1a6a8a",
|
||||
terrHi: "#5eead4",
|
||||
terrMid: "#0d9488",
|
||||
terrLo: "#0f5c55",
|
||||
peakHi: "#a7f3d0",
|
||||
peakMid: "#34d399",
|
||||
peakLo: "#065f46",
|
||||
decos: ["🌴", "🌿"],
|
||||
},
|
||||
alabasta: {
|
||||
skyTop: "#1c0a00",
|
||||
skyBot: "#3d1a00",
|
||||
seaCol: "#7c3a00",
|
||||
seaHi: "#c26010",
|
||||
terrHi: "#fde68a",
|
||||
terrMid: "#d97706",
|
||||
terrLo: "#78350f",
|
||||
peakHi: "#fef3c7",
|
||||
peakMid: "#fbbf24",
|
||||
peakLo: "#92400e",
|
||||
decos: ["🌵", "🏺"],
|
||||
},
|
||||
skypiea: {
|
||||
skyTop: "#1a0033",
|
||||
skyBot: "#2e0050",
|
||||
seaCol: "#4c1d95",
|
||||
seaHi: "#7c3aed",
|
||||
terrHi: "#e9d5ff",
|
||||
terrMid: "#a855f7",
|
||||
terrLo: "#581c87",
|
||||
peakHi: "#f5d0fe",
|
||||
peakMid: "#d946ef",
|
||||
peakLo: "#701a75",
|
||||
decos: ["☁️", "✨"],
|
||||
},
|
||||
};
|
||||
|
||||
/** Derive a StageTerrain from a generated arc theme for unknown arc ids. */
|
||||
const terrainFromTheme = (arcId: string, arc: QuestArc): StageTerrain => {
|
||||
if (KNOWN_STAGE_TERRAIN[arcId]) return KNOWN_STAGE_TERRAIN[arcId];
|
||||
const theme = generateArcTheme(arc);
|
||||
return {
|
||||
// Sky: very dark version of the theme bg colours
|
||||
skyTop: theme.bgFrom,
|
||||
skyBot: theme.bgTo,
|
||||
// Sea: use accentDark as the deep sea colour, accent as the highlight
|
||||
seaCol: theme.accentDark,
|
||||
seaHi: theme.accent,
|
||||
// Terrain: map terrain colours directly
|
||||
terrHi: theme.terrain.l,
|
||||
terrMid: theme.terrain.m,
|
||||
terrLo: theme.terrain.d,
|
||||
// Peak: lighten accent for highlights, use terrain dark for shadow
|
||||
peakHi: theme.accent,
|
||||
peakMid: theme.terrain.m,
|
||||
peakLo: theme.terrain.d,
|
||||
decos: theme.decos.slice(0, 2),
|
||||
};
|
||||
};
|
||||
|
||||
// ─── 3D Island Stage ──────────────────────────────────────────────────────────
|
||||
const IslandStage = ({
|
||||
arc,
|
||||
arcId,
|
||||
status,
|
||||
nodeIndex,
|
||||
}: {
|
||||
arc: QuestArc;
|
||||
arcId: string;
|
||||
status: QuestNode["status"];
|
||||
status: string;
|
||||
nodeIndex: number;
|
||||
}) => {
|
||||
const t = TERRAIN[arcId] ?? DEFAULT_TERRAIN;
|
||||
const t = terrainFromTheme(arcId, arc);
|
||||
const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length];
|
||||
|
||||
const isCompleted = status === "completed";
|
||||
@ -715,7 +679,7 @@ const IslandStage = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ripple rings on water surface */}
|
||||
{/* Ripple rings */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@ -737,15 +701,9 @@ const IslandStage = ({
|
||||
>
|
||||
<div
|
||||
className="qnm-island-3d"
|
||||
style={{
|
||||
// Pause rotation when locked
|
||||
animationPlayState: isLocked ? "paused" : "running",
|
||||
}}
|
||||
style={{ animationPlayState: isLocked ? "paused" : "running" }}
|
||||
>
|
||||
{/* Water base */}
|
||||
<div className="qnm-il qnm-il-water" />
|
||||
|
||||
{/* Island side face */}
|
||||
<div
|
||||
className="qnm-il qnm-il-side"
|
||||
style={{
|
||||
@ -754,8 +712,6 @@ const IslandStage = ({
|
||||
clipPath: shp.sideClip,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Island ground — shaped to match QuestMap */}
|
||||
<div
|
||||
className="qnm-il qnm-il-ground"
|
||||
style={{
|
||||
@ -767,7 +723,6 @@ const IslandStage = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Peak / hill — shaped to match QuestMap */}
|
||||
{!isLocked && (
|
||||
<div
|
||||
className="qnm-il qnm-il-peak"
|
||||
@ -796,15 +751,12 @@ const IslandStage = ({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pirate flag on active */}
|
||||
{isActive && (
|
||||
<div className="qnm-il-flag">
|
||||
<div className="qnm-flag-pole" />
|
||||
<div className="qnm-flag-cloth" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chest bouncing on claimable */}
|
||||
{isClaimable && (
|
||||
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
|
||||
<span
|
||||
@ -818,8 +770,6 @@ const IslandStage = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lock icon on locked */}
|
||||
{isLocked && (
|
||||
<div
|
||||
style={{
|
||||
@ -838,31 +788,28 @@ const IslandStage = ({
|
||||
</div>
|
||||
|
||||
{/* Sparkles for completed */}
|
||||
{isCompleted && (
|
||||
<>
|
||||
{[
|
||||
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
|
||||
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
|
||||
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
|
||||
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
|
||||
].map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="qnm-star"
|
||||
style={
|
||||
{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
"--sdur": s.sdur,
|
||||
"--sdel": s.sdel,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
✨
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{isCompleted &&
|
||||
[
|
||||
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
|
||||
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
|
||||
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
|
||||
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
|
||||
].map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="qnm-star"
|
||||
style={
|
||||
{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
"--sdur": s.sdur,
|
||||
"--sdel": s.sdel,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
✨
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Lock overlay tint */}
|
||||
{isLocked && (
|
||||
@ -886,6 +833,7 @@ const IslandStage = ({
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
interface Props {
|
||||
node: QuestNode;
|
||||
arc: QuestArc; // full arc object needed for theme generation
|
||||
arcAccent: string;
|
||||
arcDark: string;
|
||||
arcId?: string;
|
||||
@ -896,6 +844,7 @@ interface Props {
|
||||
|
||||
export const QuestNodeModal = ({
|
||||
node,
|
||||
arc,
|
||||
arcAccent,
|
||||
arcDark,
|
||||
arcId = "east_blue",
|
||||
@ -908,15 +857,19 @@ export const QuestNodeModal = ({
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// ── New field names ──────────────────────────────────────────────────────
|
||||
const progress = Math.min(
|
||||
100,
|
||||
Math.round((node.progress / node.requirement.target) * 100),
|
||||
Math.round((node.current_value / node.req_target) * 100),
|
||||
);
|
||||
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
|
||||
const howTo = HOW_TO[node.req_type];
|
||||
const remaining = Math.max(0, node.req_target - node.current_value);
|
||||
|
||||
const isClaimable = node.status === "claimable";
|
||||
const isLocked = node.status === "locked";
|
||||
const isCompleted = node.status === "completed";
|
||||
const isActive = node.status === "active";
|
||||
const howTo = HOW_TO[node.requirement.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -934,24 +887,32 @@ export const QuestNodeModal = ({
|
||||
<X size={13} color="rgba(255,255,255,0.5)" />
|
||||
</button>
|
||||
|
||||
{/* 3D island stage */}
|
||||
<IslandStage arcId={arcId} status={node.status} nodeIndex={nodeIndex} />
|
||||
{/* 3D island stage — now receives full arc for theme generation */}
|
||||
<IslandStage
|
||||
arc={arc}
|
||||
arcId={arcId}
|
||||
status={node.status}
|
||||
nodeIndex={nodeIndex}
|
||||
/>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="qnm-body">
|
||||
{/* Title */}
|
||||
{/* Title block */}
|
||||
<div className="qnm-title-block">
|
||||
<div className="qnm-arc-tag">
|
||||
{reqIcon(node.requirement.type)} Quest
|
||||
</div>
|
||||
<h2 className="qnm-quest-title">{node.title}</h2>
|
||||
<p className="qnm-island-name">📍 {node.islandName}</p>
|
||||
{/* req_type replaces node.requirement.type */}
|
||||
<div className="qnm-arc-tag">{reqIcon(node.req_type)} Quest</div>
|
||||
{/* node.name replaces node.title */}
|
||||
<h2 className="qnm-quest-title">{node.name ?? "—"}</h2>
|
||||
{/* node.islandName removed — reuse node.name as location label */}
|
||||
<p className="qnm-island-name">📍 {node.name ?? "—"}</p>
|
||||
</div>
|
||||
|
||||
{/* Flavour */}
|
||||
<div className="qnm-flavour">
|
||||
<p className="qnm-flavour-text">{node.flavourText}</p>
|
||||
</div>
|
||||
{/* Flavour — node.description replaces node.flavourText */}
|
||||
{node.description && (
|
||||
<div className="qnm-flavour">
|
||||
<p className="qnm-flavour-text">{node.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objective */}
|
||||
<div className="qnm-obj-card">
|
||||
@ -964,19 +925,18 @@ export const QuestNodeModal = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="qnm-obj-row">
|
||||
<div className="qnm-obj-icon">
|
||||
{reqIcon(node.requirement.type)}
|
||||
</div>
|
||||
<div className="qnm-obj-icon">{reqIcon(node.req_type)}</div>
|
||||
<div>
|
||||
{/* req_target + derived label replace node.requirement.target/label */}
|
||||
<p className="qnm-obj-text">
|
||||
{node.requirement.target} {node.requirement.label}
|
||||
{node.req_target} {reqLabel}
|
||||
</p>
|
||||
<p className="qnm-obj-sub">
|
||||
{isCompleted
|
||||
? "✅ Completed — treasure claimed!"
|
||||
: isLocked
|
||||
? "🔒 Complete previous quests first"
|
||||
: `${node.progress} / ${node.requirement.target} done`}
|
||||
: `${node.current_value} / ${node.req_target} done`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -990,14 +950,15 @@ export const QuestNodeModal = ({
|
||||
style={{ width: mounted ? `${progress}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
{/* current_value / req_target replace old progress / requirement.target */}
|
||||
<div className="qnm-bar-nums">
|
||||
<span>{node.progress}</span>
|
||||
<span>{node.requirement.target}</span>
|
||||
<span>{node.current_value}</span>
|
||||
<span>{node.req_target}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* How-to badges — show when active or claimable */}
|
||||
{/* How-to badges */}
|
||||
{(isActive || isClaimable) && howTo && (
|
||||
<>
|
||||
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
|
||||
@ -1036,19 +997,26 @@ export const QuestNodeModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reward */}
|
||||
{/* Reward — sources from flat node reward fields */}
|
||||
<div className="qnm-reward-card">
|
||||
<p className="qnm-reward-label">📦 Treasure Chest</p>
|
||||
<div className="qnm-reward-row">
|
||||
<div className="qnm-reward-pill">⚡ +{node.reward.xp} XP</div>
|
||||
{node.reward.title && (
|
||||
<div className="qnm-reward-pill">🏴☠️ {node.reward.title}</div>
|
||||
{/* reward_coins replaces node.reward.xp */}
|
||||
{node.reward_coins > 0 && (
|
||||
<div className="qnm-reward-pill">🪙 +{node.reward_coins}</div>
|
||||
)}
|
||||
{node.reward.itemLabel && (
|
||||
{/* reward_title is now a nested object, not a string */}
|
||||
{node.reward_title?.name && (
|
||||
<div className="qnm-reward-pill">
|
||||
🎁 {node.reward.itemLabel}
|
||||
🏴☠️ {node.reward_title.name}
|
||||
</div>
|
||||
)}
|
||||
{/* reward_items is now an array — show one pill per item */}
|
||||
{node.reward_items?.map((inv) => (
|
||||
<div key={inv.id} className="qnm-reward-pill">
|
||||
🎁 {inv.item.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1064,9 +1032,9 @@ export const QuestNodeModal = ({
|
||||
) : isLocked ? (
|
||||
<p className="qnm-note">🔒 Locked — keep sailing</p>
|
||||
) : (
|
||||
/* remaining replaces node.requirement.target - node.progress */
|
||||
<p className="qnm-note">
|
||||
{progress}% complete · {node.requirement.target - node.progress}{" "}
|
||||
{node.requirement.label} remaining
|
||||
{progress}% complete · {remaining} {reqLabel} remaining
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user