feat(quests): add 3d functionality for quests
This commit is contained in:
1102
package-lock.json
generated
1102
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
479
src/components/Island3D.tsx
Normal 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); // 7–11 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
Reference in New Issue
Block a user