feat(treasure): add treasure quest, quest modal, island node, quest widget

This commit is contained in:
shafin-r
2026-02-26 01:31:48 +06:00
parent 894863c196
commit f64d2cac4a
12 changed files with 4018 additions and 19 deletions

View File

@ -0,0 +1,718 @@
import { useState, useEffect, useRef } from "react";
import type { QuestNode } from "../types/quest";
// ─── Styles ───────────────────────────────────────────────────────────────────
const S = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Nunito:wght@800;900&display=swap');
/* ══ FULL SCREEN OVERLAY ══ */
.com-overlay {
position:fixed; inset:0; z-index:80;
display:flex; flex-direction:column;
align-items:center; justify-content:center;
overflow:hidden;
}
/* ── Sky/sea background that animates in ── */
.com-bg {
position:absolute; inset:0;
background:
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0,60,120,0.9) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 50% 20%, rgba(80,0,160,0.7) 0%, transparent 60%),
linear-gradient(180deg, #050010 0%, #0a0520 40%, #020818 100%);
animation: comBgIn 0.5s ease both;
}
@keyframes comBgIn {
from{ opacity:0; } to{ opacity:1; }
}
/* ── Stars in background ── */
.com-star {
position:absolute; border-radius:50%;
background:white; pointer-events:none;
animation:comStarTwinkle var(--sdur) ease-in-out infinite;
animation-delay:var(--sdelay);
}
@keyframes comStarTwinkle {
0%,100%{ opacity:0.3; transform:scale(1); }
50% { opacity:1; transform:scale(1.4); }
}
/* ── Gold radial burst (appears on open) ── */
.com-burst {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
pointer-events:none; z-index:2;
}
.com-burst-ring {
position:absolute; border-radius:50%;
border:3px solid rgba(251,191,36,0.6);
animation: comBurstRing var(--brdur) ease-out forwards;
animation-delay: var(--brdelay);
opacity:0;
}
@keyframes comBurstRing {
0% { opacity:0.9; transform:scale(0.1); }
100%{ opacity:0; transform:scale(var(--brs)); }
}
/* ── Ray beams (crepuscular rays) ── */
.com-rays {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
pointer-events:none; z-index:1;
}
.com-ray {
position:absolute;
width:3px;
height:55vh;
border-radius:100px;
background:linear-gradient(180deg,rgba(251,191,36,0.5) 0%,transparent 100%);
transform-origin:50% 100%;
bottom:50%;
left:calc(50% - 1.5px);
transform:rotate(var(--angle)) scaleY(0);
animation:comRayIn 0.6s ease-out forwards;
animation-delay:var(--raydelay);
}
@keyframes comRayIn {
0% { transform:rotate(var(--angle)) scaleY(0); opacity:0; }
40% { opacity:0.8; }
100%{ transform:rotate(var(--angle)) scaleY(1); opacity:0.15; }
}
/* ── Particle explosion ── */
.com-particle {
position:absolute; border-radius:50%;
pointer-events:none; z-index:4;
animation:comParticleOut var(--pdur) cubic-bezier(0.25,0.8,0.35,1) forwards;
animation-delay:var(--pdelay);
opacity:0;
}
@keyframes comParticleOut {
0% { opacity:1; transform:translate(0,0) scale(1) rotate(0deg); }
80% { opacity:0.7; }
100%{ opacity:0; transform:translate(var(--ptx),var(--pty)) scale(0.2) rotate(var(--prot)); }
}
/* ── Coin emojis bursting ── */
.com-coin {
position:absolute;
font-size:var(--csize);
pointer-events:none; z-index:4;
animation:comCoinOut var(--cdur) cubic-bezier(0.2,0.9,0.3,1) forwards;
animation-delay:var(--cdelay);
opacity:0;
}
@keyframes comCoinOut {
0% { opacity:0; transform:translate(0,0) rotate(0deg) scale(0.3); }
12% { opacity:1; }
100%{ opacity:0; transform:translate(var(--ctx),var(--cty)) rotate(var(--crot)) scale(1.1); }
}
/* ── Floating sparkles (stay on screen) ── */
.com-sparkle {
position:absolute; pointer-events:none; z-index:3;
font-size:var(--spsize);
animation:comSparkleFloat var(--spdur) ease-in-out infinite;
animation-delay:var(--spdelay);
opacity:0.7;
}
@keyframes comSparkleFloat {
0%,100%{ transform:translateY(0) rotate(0deg) scale(1); opacity:0.6; }
50% { transform:translateY(-18px) rotate(180deg) scale(1.2); opacity:1; }
}
/* ── XP number that flies up from chest ── */
.com-xp-blast {
position:absolute; pointer-events:none; z-index:5;
top:50%; left:50%;
font-family:'Cinzel',serif;
font-size:2.6rem; font-weight:900;
color:#fbbf24;
text-shadow:0 0 30px rgba(251,191,36,1),0 0 60px rgba(251,191,36,0.7),0 0 100px rgba(251,191,36,0.3);
white-space:nowrap;
animation:comXPBlast 2s cubic-bezier(0.2,0.8,0.3,1) forwards;
}
@keyframes comXPBlast {
0% { opacity:0; transform:translate(-50%,-40%) scale(0.4); filter:blur(4px); }
15% { opacity:1; transform:translate(-50%,-60%) scale(1.3); filter:blur(0); }
60% { opacity:1; transform:translate(-50%,-90%) scale(1); }
100%{ opacity:0; transform:translate(-50%,-130%) scale(0.8); }
}
/* ── Main card ── */
.com-card {
position:relative; z-index:6;
width:calc(100% - 2.5rem); max-width:340px;
border-radius:28px; overflow:hidden;
display:flex; flex-direction:column; align-items:center;
padding:0;
box-shadow:0 0 80px rgba(251,191,36,0.2), 0 24px 64px rgba(0,0,0,0.7);
animation:comCardIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay:0.1s;
}
@keyframes comCardIn {
from{ opacity:0; transform:scale(0.8) translateY(24px); }
to { opacity:1; transform:scale(1) translateY(0); }
}
/* Gold shimmer top border */
.com-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:1;
background:linear-gradient(90deg,transparent 0%,#f59e0b 30%,#fbbf24 50%,#f59e0b 70%,transparent 100%);
background-size:200% 100%;
animation:comShimmer 2s linear infinite;
}
@keyframes comShimmer {
0% { background-position:200% 0; }
100%{ background-position:-200% 0; }
}
/* Card inner bg */
.com-card-inner {
width:100%; padding:1.75rem 1.6rem 1.6rem;
background:linear-gradient(160deg,#12083a 0%,#0c0525 60%,#090320 100%);
border:1.5px solid rgba(251,191,36,0.25);
border-radius:28px;
display:flex; flex-direction:column; align-items:center; gap:0;
}
/* ── Phase label ── */
.com-label {
font-family:'Cinzel',serif;
font-size:0.62rem; font-weight:700; letter-spacing:0.2em;
text-transform:uppercase; color:rgba(251,191,36,0.55);
margin-bottom:1.2rem; text-align:center;
}
/* ── Chest area ── */
.com-chest-area {
position:relative; width:140px; height:140px;
display:flex; align-items:center; justify-content:center;
margin-bottom:1.25rem; cursor:pointer;
}
/* Glow platform beneath chest */
.com-glow-pad {
position:absolute; bottom:6px; left:50%;
transform:translateX(-50%);
width:100px; height:24px; border-radius:50%;
background:radial-gradient(ellipse at center,rgba(251,191,36,0.45) 0%,transparent 70%);
animation:comGlowPad 1.8s ease-in-out infinite;
filter:blur(4px);
}
@keyframes comGlowPad {
0%,100%{ transform:translateX(-50%) scaleX(1); opacity:0.7; }
50% { transform:translateX(-50%) scaleX(1.2); opacity:1; }
}
/* Orbit ring */
.com-orbit {
position:absolute; inset:8px; border-radius:50%;
border:1.5px dashed rgba(251,191,36,0.2);
animation:comOrbit 8s linear infinite;
}
@keyframes comOrbit { from{transform:rotate(0deg);} to{transform:rotate(360deg);} }
.com-orbit-dot {
position:absolute; top:-5px; left:50%; transform:translateX(-50%);
width:8px; height:8px; border-radius:50%;
background:#fbbf24; box-shadow:0 0 10px #fbbf24;
}
/* The chest emoji */
.com-chest {
font-size:5.5rem; position:relative; z-index:2;
filter:drop-shadow(0 8px 20px rgba(251,191,36,0.45));
transition:filter 0.2s;
}
.com-chest.idle {
animation:comChestIdle 3s ease-in-out infinite;
}
@keyframes comChestIdle {
0%,100%{ transform:translateY(0) rotate(-2deg); }
50% { transform:translateY(-6px) rotate(2deg); }
}
.com-chest.shake {
animation:comChestShake 0.55s cubic-bezier(0.36,0.07,0.19,0.97) both;
}
@keyframes comChestShake {
0%,100%{ transform:rotate(0deg) scale(1); }
10% { transform:rotate(-14deg) scale(1.06); }
25% { transform:rotate(14deg) scale(1.1); }
40% { transform:rotate(-10deg) scale(1.07); }
55% { transform:rotate(10deg) scale(1.12); }
70% { transform:rotate(-6deg) scale(1.06); }
85% { transform:rotate(6deg) scale(1.04); }
}
.com-chest.opening {
animation:comChestOpen 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes comChestOpen {
0% { transform:scale(0.7); filter:brightness(0.4) drop-shadow(0 8px 20px rgba(251,191,36,0.3)); }
50% { transform:scale(1.25); filter:brightness(1.8) drop-shadow(0 0 50px rgba(251,191,36,1)); }
100%{ transform:scale(1); filter:brightness(1) drop-shadow(0 8px 30px rgba(251,191,36,0.6)); }
}
/* ── Tap prompt ── */
.com-tap-title {
font-family:'Cinzel',serif;
font-size:1.2rem; font-weight:900; color:white;
text-align:center; margin-bottom:0.3rem;
animation:comPulse 1.8s ease-in-out infinite;
}
@keyframes comPulse {
0%,100%{ opacity:1; transform:scale(1); }
50% { opacity:0.65; transform:scale(0.97); }
}
.com-tap-sub {
font-family:'Nunito',sans-serif;
font-size:0.75rem; font-weight:800;
color:rgba(255,255,255,0.35); text-align:center; margin-bottom:1.5rem;
letter-spacing:0.06em;
}
/* ── Shaking text ── */
.com-shake-text {
font-family:'Cinzel',serif;
font-size:1.1rem; font-weight:900; color:#fbbf24;
text-align:center; margin-bottom:0.3rem;
animation:comShakeText 0.3s ease-in-out infinite alternate;
}
@keyframes comShakeText {
from{ transform:translateX(-3px); }
to { transform:translateX(3px); }
}
.com-shake-dots {
font-size:1.4rem; text-align:center; margin-bottom:1.5rem;
animation:comShakeText 0.25s ease-in-out infinite alternate;
}
/* ── Reward rows ── */
.com-rewards-title {
font-family:'Cinzel',serif;
font-size:0.65rem; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:rgba(251,191,36,0.5);
text-align:center; margin-bottom:0.85rem;
}
.com-rewards { display:flex; flex-direction:column; gap:0.55rem; width:100%; margin-bottom:1.1rem; }
.com-reward-row {
display:flex; align-items:center; gap:0.85rem;
padding:0.8rem 1rem;
background:rgba(255,255,255,0.04);
border:1px solid rgba(251,191,36,0.18);
border-radius:16px;
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes comRowIn {
from{ opacity:0; transform:translateY(18px) scale(0.88); }
to { opacity:1; transform:translateY(0) scale(1); }
}
.com-reward-icon { font-size:1.5rem; flex-shrink:0; filter:drop-shadow(0 2px 8px rgba(251,191,36,0.5)); }
.com-reward-lbl {
font-family:'Cinzel',serif;
font-size:0.65rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase;
color:rgba(255,255,255,0.4); margin-bottom:0.12rem;
}
.com-reward-val {
font-family:'Nunito',sans-serif;
font-size:1.05rem; font-weight:900;
color:#fbbf24;
text-shadow:0 0 16px rgba(251,191,36,0.6);
}
/* Special XP row highlight */
.com-reward-row.xp-row {
border-color:rgba(251,191,36,0.35);
background:rgba(251,191,36,0.06);
}
/* ── CTA button ── */
.com-cta {
width:100%; padding:1rem;
background:linear-gradient(135deg,#fbbf24,#f59e0b);
border:none; border-radius:16px; cursor:pointer;
font-family:'Cinzel',serif;
font-size:1rem; font-weight:900; color:#1a0800;
letter-spacing:0.05em;
box-shadow:0 5px 0 #b45309, 0 8px 24px rgba(251,191,36,0.4);
transition:all 0.12s ease;
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
}
.com-cta:hover { transform:translateY(-3px); box-shadow:0 8px 0 #b45309, 0 14px 32px rgba(251,191,36,0.5); }
.com-cta:active { transform:translateY(2px); box-shadow:0 3px 0 #b45309; }
/* ── Skip hint ── */
.com-skip {
position:absolute; bottom:1.5rem;
font-family:'Nunito',sans-serif;
font-size:0.65rem; font-weight:700;
color:rgba(255,255,255,0.2); letter-spacing:0.1em;
text-transform:uppercase; cursor:pointer; z-index:7;
transition:color 0.2s;
}
.com-skip:hover { color:rgba(255,255,255,0.5); }
`;
// ─── Config ───────────────────────────────────────────────────────────────────
const PARTICLE_COLORS = [
"#fbbf24",
"#f59e0b",
"#ef4444",
"#ec4899",
"#a855f7",
"#6366f1",
"#22d3ee",
"#4ade80",
"#fb923c",
];
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
// Rays at evenly spaced angles
const RAYS = Array.from({ length: 12 }, (_, i) => ({
id: i,
angle: `${(i / 12) * 360}deg`,
delay: `${i * 0.04}s`,
}));
// Burst rings
const BURST_RINGS = [
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
{ id: 2, size: "8", dur: "1.1s", delay: "0.2s" },
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
];
// Stars in background — stable between renders
const STARS = Array.from({ length: 40 }, (_, i) => ({
id: i,
w: 1 + ((i * 7) % 3),
top: `${(i * 17 + 3) % 95}%`,
left: `${(i * 23 + 11) % 97}%`,
dur: `${2 + ((i * 3) % 4)}s`,
delay: `${(i * 7) % 3}s`,
}));
// Sparkles floating around the revealed card
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
id: i,
emoji: SPARKLE_EMOJIS[i % 4],
size: `${0.9 + (i % 3) * 0.35}rem`,
top: `${10 + ((i * 12) % 75)}%`,
left: `${5 + ((i * 14) % 85)}%`,
dur: `${2 + (i % 3) * 1.2}s`,
delay: `${i * 0.3}s`,
}));
type Phase = "idle" | "shaking" | "opening" | "revealed";
interface Props {
node: QuestNode;
onClose: () => void;
}
export const ChestOpenModal = ({ node, onClose }: Props) => {
const [phase, setPhase] = useState<Phase>("idle");
const [showXP, setShowXP] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable particle arrays computed once per mount
const particles = useRef(
Array.from({ length: 55 }, (_, i) => ({
id: i,
color: PARTICLE_COLORS[i % PARTICLE_COLORS.length],
w: 3 + (i % 3) * 4,
tx: ((i % 2 === 0 ? 1 : -1) * (40 + i * 7)) % 200,
ty: -(30 + ((i * 11) % 190)),
rot: ((i * 23) % 720) - 360,
dur: `${0.7 + ((i * 7) % 10) / 10}s`,
delay: `${((i * 3) % 8) / 30}s`,
})),
).current;
const coins = useRef(
Array.from({ length: 18 }, (_, i) => ({
id: i,
emoji: COIN_EMOJIS[i % COIN_EMOJIS.length],
size: `${1 + (i % 3) * 0.45}rem`,
tx: (i % 2 === 0 ? 1 : -1) * (30 + ((i * 9) % 180)),
ty: -(40 + ((i * 13) % 200)),
rot: ((i * 31) % 540) - 270,
dur: `${0.75 + ((i * 7) % 8) / 10}s`,
delay: `${((i * 5) % 10) / 30}s`,
})),
).current;
const tap = () => {
if (phase !== "idle") return;
setPhase("shaking");
timerRef.current = setTimeout(() => {
setPhase("opening");
setShowXP(true);
timerRef.current = setTimeout(() => {
setShowXP(false);
setPhase("revealed");
}, 1800);
}, 650);
};
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
const rewards = [
{
key: "xp",
cls: "xp-row",
icon: "⚡",
lbl: "XP Gained",
val: `+${node.reward.xp} XP`,
delay: "0.05s",
},
...(node.reward.title
? [
{
key: "title",
cls: "",
icon: "🏴‍☠️",
lbl: "Crew Title",
val: node.reward.title,
delay: "0.15s",
},
]
: []),
...(node.reward.itemLabel
? [
{
key: "item",
cls: "",
icon: "🎁",
lbl: "Item",
val: node.reward.itemLabel,
delay: "0.25s",
},
]
: []),
];
const chestClass =
phase === "idle"
? "idle"
: phase === "shaking"
? "shake"
: phase === "opening"
? "opening"
: "";
const chestEmoji = phase === "opening" || phase === "revealed" ? "📬" : "📦";
return (
<div className="com-overlay" onClick={phase === "idle" ? tap : undefined}>
<style>{S}</style>
{/* Background */}
<div className="com-bg" />
{/* Stars */}
{STARS.map((s) => (
<div
key={s.id}
className="com-star"
style={
{
width: s.w,
height: s.w,
top: s.top,
left: s.left,
"--sdur": s.dur,
"--sdelay": s.delay,
} as React.CSSProperties
}
/>
))}
{/* Crepuscular rays (appear on open) */}
{(phase === "opening" || phase === "revealed") && (
<div className="com-rays">
{RAYS.map((r) => (
<div
key={r.id}
className="com-ray"
style={
{
"--angle": r.angle,
"--raydelay": r.delay,
} as React.CSSProperties
}
/>
))}
</div>
)}
{/* Burst rings */}
{(phase === "opening" || phase === "revealed") && (
<div className="com-burst">
{BURST_RINGS.map((r) => (
<div
key={r.id}
className="com-burst-ring"
style={
{
width: "100px",
height: "100px",
"--brs": r.size,
"--brdur": r.dur,
"--brdelay": r.delay,
} as React.CSSProperties
}
/>
))}
</div>
)}
{/* Particle explosion */}
{(phase === "opening" || phase === "revealed") && (
<>
{particles.map((p) => (
<div
key={p.id}
className="com-particle"
style={
{
width: p.w,
height: p.w,
background: p.color,
top: "50%",
left: "50%",
"--ptx": `${p.tx}px`,
"--pty": `${p.ty}px`,
"--prot": `${p.rot}deg`,
"--pdur": p.dur,
"--pdelay": p.delay,
} as React.CSSProperties
}
/>
))}
{coins.map((c) => (
<div
key={c.id}
className="com-coin"
style={
{
top: "50%",
left: "50%",
"--csize": c.size,
"--ctx": `${c.tx}px`,
"--cty": `${c.ty}px`,
"--crot": `${c.rot}deg`,
"--cdur": c.dur,
"--cdelay": c.delay,
} as React.CSSProperties
}
>
{c.emoji}
</div>
))}
</>
)}
{/* Floating sparkles in revealed state */}
{phase === "revealed" &&
SPARKLES.map((sp) => (
<div
key={sp.id}
className="com-sparkle"
style={
{
top: sp.top,
left: sp.left,
"--spsize": sp.size,
"--spdur": sp.dur,
"--spdelay": sp.delay,
} as React.CSSProperties
}
>
{sp.emoji}
</div>
))}
{/* XP blast */}
{showXP && <div className="com-xp-blast">+{node.reward.xp} XP</div>}
{/* Card */}
<div className="com-card" onClick={(e) => e.stopPropagation()}>
<div className="com-card-inner">
<p className="com-label">
{phase === "revealed" ? "⚓ Treasure Claimed" : "📦 Treasure Chest"}
</p>
{/* Chest */}
<div
className="com-chest-area"
onClick={phase === "idle" ? tap : undefined}
style={{ cursor: phase === "idle" ? "pointer" : "default" }}
>
{phase !== "revealed" && <div className="com-glow-pad" />}
{phase !== "revealed" && (
<div className="com-orbit">
<div className="com-orbit-dot" />
</div>
)}
<span className={`com-chest ${chestClass}`}>{chestEmoji}</span>
</div>
{/* Phase content */}
{phase === "idle" && (
<>
<p className="com-tap-title">Tap to Open!</p>
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
</>
)}
{phase === "shaking" && (
<>
<p className="com-shake-text">The chest stirs...</p>
<p className="com-shake-dots"> </p>
</>
)}
{phase === "revealed" && (
<>
<p className="com-rewards-title"> Spoils of Victory</p>
<div className="com-rewards">
{rewards.map((r) => (
<div
key={r.key}
className={`com-reward-row ${r.cls}`}
style={{ animationDelay: r.delay }}
>
<span className="com-reward-icon">{r.icon}</span>
<div>
<p className="com-reward-lbl">{r.lbl}</p>
<p className="com-reward-val">{r.val}</p>
</div>
</div>
))}
</div>
<button
className="com-cta"
style={{ animationDelay: rewards.length * 0.1 + "s" }}
onClick={onClose}
>
Set Sail
</button>
</>
)}
</div>
</div>
{/* Skip link for impatient pirates */}
{phase === "revealed" && (
<p className="com-skip" onClick={onClose}>
tap anywhere to continue
</p>
)}
</div>
);
};