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