feat(quests): add 3d functionality for quests

This commit is contained in:
shafin-r
2026-03-11 03:17:08 +06:00
parent 575d392afc
commit f00aad2bbd
4 changed files with 2270 additions and 1169 deletions

1102
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3",
"canvas-confetti": "^1.9.4",
@ -23,6 +25,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.30.0",
"katex": "^0.16.28",
"leva": "^0.10.1",
"lucide-react": "^0.562.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
@ -31,6 +34,7 @@
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"three": "^0.183.2",
"vaul": "^1.1.2",
"zustand": "^5.0.9"
},

479
src/components/Island3D.tsx Normal file
View File

@ -0,0 +1,479 @@
// Island3D.tsx
import { useRef, useState, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { Float, Text, Html, Sparkles, Billboard } from "@react-three/drei";
import * as THREE from "three";
import type { QuestNode } from "../types/quest";
export interface Island3DProps {
node: QuestNode;
position: [number, number, number];
accent: string;
terrain: { l: string; m: string; d: string; s: string };
onTap: (node: QuestNode) => void;
onClaim: (node: QuestNode) => void;
index: number;
/** When true the info card drops behind the modal overlay */
modalOpen?: boolean;
}
// ─── Seeded RNG (xorshift32) ──────────────────────────────────────────────────
const makeRng = (seed: number) => {
let s = ((seed + 1) * 1664525 + 1013904223) >>> 0;
return () => {
s ^= s << 13;
s ^= s >>> 17;
s ^= s << 5;
return (s >>> 0) / 4294967296;
};
};
// ─── Irregular island Shape for ExtrudeGeometry ───────────────────────────────
// ExtrudeGeometry computes normals correctly — no manual BufferGeometry needed.
const makeIslandShape = (seed: number, radiusBase = 1.0): THREE.Shape => {
const rng = makeRng(seed);
const sides = 7 + Math.floor(rng() * 5); // 711 sides
const pts: THREE.Vector2[] = [];
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2 - Math.PI / 2;
const radius = radiusBase * (0.82 + rng() * 0.42);
pts.push(
new THREE.Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius),
);
}
const shape = new THREE.Shape(pts);
return shape;
};
// ─── Status palette ───────────────────────────────────────────────────────────
const PALETTE = {
LOCKED: { top: "#778fa0", side: "#526070", emissive: "#000000", ei: 0 },
ACTIVE: { top: "", side: "", emissive: "#ffffff", ei: 0 },
CLAIMABLE: { top: "#f59e0b", side: "#d97706", emissive: "#fde68a", ei: 0.3 },
COMPLETED: { top: "#22c55e", side: "#16a34a", emissive: "#bbf7d0", ei: 0.1 },
};
// ─── Card scale ───────────────────────────────────────────────────────────────
// Change this single number to resize the entire info card.
// 1.0 = default. 0.8 = smaller, 1.3 = larger.
const CARD_SCALE = 0.8;
// Derived sizes — do not edit these directly
const C = {
width: 175 * CARD_SCALE,
borderRadius: 13 * CARD_SCALE,
padV: 10 * CARD_SCALE,
padH: 12 * CARD_SCALE,
padVb: 9 * CARD_SCALE,
nameSize: 12.5 * CARD_SCALE,
xpSize: 11 * CARD_SCALE,
statusSize: 10 * CARD_SCALE,
xpPadV: 2 * CARD_SCALE,
xpPadH: 8 * CARD_SCALE,
xpRadius: 100 * CARD_SCALE,
gapRow: 6 * CARD_SCALE,
nameMb: 7 * CARD_SCALE,
barMt: 8 * CARD_SCALE,
barH: 4 * CARD_SCALE,
btnMt: 9 * CARD_SCALE,
btnPadV: 6 * CARD_SCALE,
btnSize: 11 * CARD_SCALE,
btnRadius: 9 * CARD_SCALE,
// Html position: how far the card sits from the island centre
posX: 2.1 * CARD_SCALE,
};
const REQ_EMOJI: Record<string, string> = {
questions: "❓",
accuracy: "🎯",
streak: "🔥",
sessions: "📚",
topics: "🗺️",
xp: "⚡",
leaderboard: "🏆",
};
export const Island3D = ({
node,
position,
accent,
terrain,
onTap,
onClaim,
index,
modalOpen = false,
}: Island3DProps) => {
const topMeshRef = useRef<THREE.Mesh>(null!);
const [hovered, setHovered] = useState(false);
const status = (node.status ?? "LOCKED") as keyof typeof PALETTE;
const isLocked = status === "LOCKED";
const isClaimable = status === "CLAIMABLE";
const isActive = status === "ACTIVE";
const isCompleted = status === "COMPLETED";
// ── Geometry — ExtrudeGeometry gives correct normals, solid faces ──────────
const { topGeo, cliffGeo } = useMemo(() => {
const seed = index * 13 + 7;
// Top plateau shape (slightly smaller to show cliff peeking out below)
const topShape = makeIslandShape(seed, 1.0);
const topGeo = new THREE.ExtrudeGeometry(topShape, {
depth: 0.3,
bevelEnabled: true,
bevelThickness: 0.1,
bevelSize: 0.07,
bevelSegments: 4,
});
// ExtrudeGeometry extrudes along Z — rotate to lie flat (XZ plane)
topGeo.rotateX(-Math.PI / 2);
// Shift so top face is at y=0, bottom at y=-0.3
topGeo.translate(0, 0, 0);
// Cliff body — same seed so outline matches, just a bit wider & taller
const cliffShape = makeIslandShape(seed, 1.12);
const cliffGeo = new THREE.ExtrudeGeometry(cliffShape, {
depth: 0.55,
bevelEnabled: true,
bevelThickness: 0.05,
bevelSize: 0.04,
bevelSegments: 2,
});
cliffGeo.rotateX(-Math.PI / 2);
cliffGeo.translate(0, -0.3, 0); // sit directly below top plateau
return { topGeo, cliffGeo };
}, [index]);
// ── Colours ───────────────────────────────────────────────────────────────
const pal = PALETTE[status];
const topColor = isActive
? terrain.l
: isCompleted
? PALETTE.COMPLETED.top
: pal.top;
const sideColor = isActive
? terrain.m
: isCompleted
? PALETTE.COMPLETED.side
: pal.side;
// ── Hover scale spring ────────────────────────────────────────────────────
useFrame((_, dt) => {
if (!topMeshRef.current) return;
const target = hovered && !isLocked ? 1.07 : 1.0;
const curr = topMeshRef.current.scale.x;
const next = curr + (target - curr) * (1 - Math.exp(-14 * dt));
topMeshRef.current.scale.setScalar(next);
});
// ── Emoji ────────────────────────────────────────────────────────────────
const emoji = isLocked
? "🔒"
: isClaimable
? "📦"
: isCompleted
? "✅"
: (REQ_EMOJI[node.req_type] ?? "🏝️");
const pct =
node.req_target > 0
? Math.min(100, Math.round((node.current_value / node.req_target) * 100))
: 0;
const ringColor = isClaimable ? "#fbbf24" : isCompleted ? "#4ade80" : accent;
return (
<Float
rotationIntensity={isLocked ? 0 : 0.05}
floatIntensity={isLocked ? 0 : 0.35}
speed={1.0 + (index % 4) * 0.2}
>
<group
position={position}
onPointerEnter={(e) => {
e.stopPropagation();
if (!isLocked) setHovered(true);
}}
onPointerLeave={(e) => {
e.stopPropagation();
setHovered(false);
}}
onClick={(e) => {
e.stopPropagation();
if (!isLocked) onTap(node);
}}
>
{/* ── Top plateau — solid, opaque ────────────────────────────────── */}
<mesh ref={topMeshRef} geometry={topGeo} castShadow receiveShadow>
<meshStandardMaterial
color={topColor}
emissive={pal.emissive}
emissiveIntensity={hovered ? pal.ei * 2.2 : pal.ei}
roughness={0.55}
metalness={0.06}
/>
</mesh>
{/* ── Cliff body — solid, darker shade ──────────────────────────── */}
<mesh geometry={cliffGeo} castShadow>
<meshStandardMaterial
color={sideColor}
roughness={0.82}
metalness={0.02}
emissive={pal.emissive}
emissiveIntensity={pal.ei * 0.25}
/>
</mesh>
{/* ── Water-level glow ring ──────────────────────────────────────── */}
{!isLocked && (
<mesh rotation-x={-Math.PI / 2} position-y={-0.86}>
<ringGeometry args={[1.3, 1.7, 48]} />
<meshStandardMaterial
color={ringColor}
emissive={ringColor}
emissiveIntensity={isClaimable ? 1.0 : 0.4}
transparent
opacity={isClaimable ? 0.65 : 0.25}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
)}
{/* ── Claimable sparkles + top ring ─────────────────────────────── */}
{isClaimable && (
<>
<Sparkles
count={30}
scale={2.6}
size={0.22}
speed={1.2}
color="#fbbf24"
/>
<mesh rotation-x={-Math.PI / 2} position-y={0.05}>
<ringGeometry args={[1.05, 1.4, 40]} />
<meshStandardMaterial
color="#fbbf24"
emissive="#fbbf24"
emissiveIntensity={0.8}
transparent
opacity={0.5}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
</>
)}
{/* ── Hover ring ────────────────────────────────────────────────── */}
{hovered && !isLocked && (
<mesh rotation-x={-Math.PI / 2} position-y={0.06}>
<ringGeometry args={[1.0, 1.65, 40]} />
<meshStandardMaterial
color="white"
emissive="white"
emissiveIntensity={0.5}
transparent
opacity={0.14}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
)}
{/* ── Billboard emoji above island ───────────────────────────────── */}
<Billboard>
<Text
position={[0, 1.3, 0]}
fontSize={0.44}
anchorX="center"
anchorY="middle"
>
{emoji}
</Text>
</Billboard>
{/* ── Info card ─────────────────────────────────────────────────── */}
<Html
position={[C.posX, 0.1, 0]}
transform={false}
occlude={false}
zIndexRange={modalOpen ? [0, 0] : [50, 0]}
style={{
pointerEvents: isLocked || modalOpen ? "none" : "auto",
opacity: modalOpen ? 0 : 1,
transition: "opacity 0.15s ease",
}}
>
<div
onClick={(e) => {
e.stopPropagation();
if (!isLocked) onTap(node);
}}
style={{
width: C.width,
background: isClaimable
? "rgba(28,18,4,0.96)"
: isLocked
? "rgba(12,18,28,0.72)"
: "rgba(5,12,24,0.93)",
border: `1px solid ${
isClaimable
? "rgba(251,191,36,0.65)"
: isCompleted
? "rgba(74,222,128,0.45)"
: isActive
? `${accent}55`
: "rgba(255,255,255,0.1)"
}`,
borderLeft: `3px solid ${
isClaimable
? "#fbbf24"
: isCompleted
? "#4ade80"
: isActive
? accent
: "rgba(255,255,255,0.2)"
}`,
borderRadius: C.borderRadius,
padding: `${C.padV}px ${C.padH}px ${C.padVb}px`,
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
boxShadow: isClaimable
? "0 0 24px rgba(251,191,36,0.28), 0 6px 20px rgba(0,0,0,0.6)"
: "0 6px 20px rgba(0,0,0,0.55)",
opacity: isLocked ? 0.5 : 1,
cursor: isLocked ? "default" : "pointer",
userSelect: "none",
fontFamily: "'Nunito', 'Nunito Sans', sans-serif",
}}
>
{/* Name */}
<div
style={{
fontWeight: 800,
fontSize: C.nameSize,
color: isClaimable
? "#fde68a"
: isLocked
? "rgba(255,255,255,0.28)"
: "rgba(255,255,255,0.93)",
lineHeight: 1.3,
marginBottom: C.nameMb,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{node.name ?? "—"}
</div>
{/* XP + status */}
<div
style={{
display: "flex",
alignItems: "center",
gap: C.gapRow,
flexWrap: "wrap",
}}
>
<span
style={{
background: "rgba(251,191,36,0.13)",
border: "1px solid rgba(251,191,36,0.3)",
borderRadius: C.xpRadius,
padding: `${C.xpPadV}px ${C.xpPadH}px`,
fontSize: C.xpSize,
fontWeight: 900,
color: "#fbbf24",
whiteSpace: "nowrap",
}}
>
{node.reward_xp} XP
</span>
<span
style={{
fontSize: C.statusSize,
fontWeight: 800,
color: isClaimable
? "#fbbf24"
: isCompleted
? "#4ade80"
: isActive
? "rgba(255,255,255,0.5)"
: "rgba(255,255,255,0.18)",
textTransform: "uppercase",
letterSpacing: "0.07em",
whiteSpace: "nowrap",
}}
>
{isClaimable
? "✨ Claim!"
: isCompleted
? "Done"
: isLocked
? "Locked"
: `${pct}%`}
</span>
</div>
{/* Progress bar */}
{isActive && node.req_target > 0 && (
<div
style={{
marginTop: C.barMt,
height: C.barH,
background: "rgba(255,255,255,0.08)",
borderRadius: 100,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${pct}%`,
background: `linear-gradient(90deg, ${accent}, #e2e8f0)`,
borderRadius: 100,
boxShadow: `0 0 6px ${accent}88`,
}}
/>
</div>
)}
{/* Claim button */}
{isClaimable && (
<button
onClick={(e) => {
e.stopPropagation();
onClaim(node);
}}
style={{
width: "100%",
marginTop: C.btnMt,
padding: `${C.btnPadV}px 0`,
background: "linear-gradient(135deg, #fbbf24, #f59e0b)",
border: "none",
borderRadius: C.btnRadius,
cursor: "pointer",
fontFamily: "inherit",
fontSize: C.btnSize,
fontWeight: 900,
color: "#1a0e00",
letterSpacing: "0.04em",
boxShadow:
"0 2px 0 #d97706, 0 4px 14px rgba(251,191,36,0.28)",
}}
>
Open Chest
</button>
)}
</div>
</Html>
</group>
</Float>
);
};

File diff suppressed because it is too large Load Diff