web #1
@ -13,11 +13,11 @@ export interface Island3DProps {
|
||||
onTap: (node: QuestNode) => void;
|
||||
onClaim: (node: QuestNode) => void;
|
||||
index: number;
|
||||
/** When true the info card drops behind the modal overlay */
|
||||
isCurrent?: boolean;
|
||||
modalOpen?: boolean;
|
||||
}
|
||||
|
||||
// ─── Seeded RNG (xorshift32) ──────────────────────────────────────────────────
|
||||
// ─── Seeded RNG ───────────────────────────────────────────────────────────────
|
||||
const makeRng = (seed: number) => {
|
||||
let s = ((seed + 1) * 1664525 + 1013904223) >>> 0;
|
||||
return () => {
|
||||
@ -28,23 +28,19 @@ const makeRng = (seed: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Irregular island Shape for ExtrudeGeometry ───────────────────────────────
|
||||
// ExtrudeGeometry computes normals correctly — no manual BufferGeometry needed.
|
||||
// ─── Island shape ─────────────────────────────────────────────────────────────
|
||||
const makeIslandShape = (seed: number, radiusBase = 1.0): THREE.Shape => {
|
||||
const rng = makeRng(seed);
|
||||
const sides = 7 + Math.floor(rng() * 5); // 7–11 sides
|
||||
const sides = 8 + Math.floor(rng() * 5);
|
||||
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);
|
||||
const radius = radiusBase * (0.78 + rng() * 0.48);
|
||||
pts.push(
|
||||
new THREE.Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius),
|
||||
);
|
||||
}
|
||||
|
||||
const shape = new THREE.Shape(pts);
|
||||
return shape;
|
||||
return new THREE.Shape(pts);
|
||||
};
|
||||
|
||||
// ─── Status palette ───────────────────────────────────────────────────────────
|
||||
@ -52,15 +48,11 @@ 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 },
|
||||
COMPLETED: { top: "#d4a843", side: "#a07830", emissive: "#ffe8a0", ei: 0.15 },
|
||||
};
|
||||
|
||||
// ─── 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,
|
||||
@ -81,7 +73,6 @@ const C = {
|
||||
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,
|
||||
};
|
||||
|
||||
@ -95,6 +86,591 @@ const REQ_EMOJI: Record<string, string> = {
|
||||
leaderboard: "🏆",
|
||||
};
|
||||
|
||||
// ─── Palm tree ────────────────────────────────────────────────────────────────
|
||||
const PalmTree = ({
|
||||
position,
|
||||
scale,
|
||||
seed,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale: number;
|
||||
seed: number;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 91 + 3);
|
||||
const trunkLean = (rng() - 0.5) * 0.35;
|
||||
const trunkHeight = 0.55 + rng() * 0.25;
|
||||
const frondCount = 5 + Math.floor(rng() * 3);
|
||||
|
||||
const trunkGeo = useMemo(
|
||||
() => new THREE.CylinderGeometry(0.028, 0.045, trunkHeight, 6),
|
||||
[trunkHeight],
|
||||
);
|
||||
const frondGeo = useMemo(() => new THREE.ConeGeometry(0.22, 0.18, 4), []);
|
||||
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
<mesh
|
||||
geometry={trunkGeo}
|
||||
position={[0, trunkHeight / 2, 0]}
|
||||
rotation-z={trunkLean}
|
||||
castShadow
|
||||
>
|
||||
<meshStandardMaterial color="#7c5c3a" roughness={0.9} />
|
||||
</mesh>
|
||||
{Array.from({ length: frondCount }, (_, i) => {
|
||||
const angle = (i / frondCount) * Math.PI * 2;
|
||||
const tilt = 0.5 + rng() * 0.3;
|
||||
const r = 0.18 + rng() * 0.06;
|
||||
return (
|
||||
<mesh
|
||||
key={i}
|
||||
geometry={frondGeo}
|
||||
position={[
|
||||
Math.sin(angle) * r + Math.sin(trunkLean) * trunkHeight,
|
||||
trunkHeight + 0.04,
|
||||
Math.cos(angle) * r,
|
||||
]}
|
||||
rotation={[tilt, angle, 0]}
|
||||
castShadow
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={`hsl(${120 + Math.floor(rng() * 30)}, 65%, ${28 + Math.floor(rng() * 14)}%)`}
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{rng() > 0.4 && (
|
||||
<mesh
|
||||
position={[
|
||||
Math.sin(trunkLean) * trunkHeight * 0.9,
|
||||
trunkHeight - 0.05,
|
||||
0,
|
||||
]}
|
||||
>
|
||||
<sphereGeometry args={[0.045, 6, 6]} />
|
||||
<meshStandardMaterial color="#5c3d1a" roughness={0.9} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Pine / jungle tree ───────────────────────────────────────────────────────
|
||||
const PineTree = ({
|
||||
position,
|
||||
scale,
|
||||
seed,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale: number;
|
||||
seed: number;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 37 + 11);
|
||||
const layers = 2 + Math.floor(rng() * 2);
|
||||
const hue = 110 + Math.floor(rng() * 40);
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
<mesh position={[0, 0.15, 0]} castShadow>
|
||||
<cylinderGeometry args={[0.03, 0.05, 0.3, 5]} />
|
||||
<meshStandardMaterial color="#5c3d1a" roughness={0.95} />
|
||||
</mesh>
|
||||
{Array.from({ length: layers }, (_, i) => (
|
||||
<mesh key={i} position={[0, 0.28 + i * 0.22, 0]} castShadow>
|
||||
<coneGeometry args={[0.28 - i * 0.06, 0.32, 7]} />
|
||||
<meshStandardMaterial
|
||||
color={`hsl(${hue}, 60%, ${22 + i * 6}%)`}
|
||||
roughness={0.85}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Rock cluster ─────────────────────────────────────────────────────────────
|
||||
const RockCluster = ({
|
||||
position,
|
||||
scale,
|
||||
seed,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale: number;
|
||||
seed: number;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 53 + 7);
|
||||
const count = 2 + Math.floor(rng() * 3);
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const angle = rng() * Math.PI * 2;
|
||||
const r = rng() * 0.12;
|
||||
const s = 0.06 + rng() * 0.1;
|
||||
const grey = Math.floor(90 + rng() * 80);
|
||||
return (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[Math.sin(angle) * r, s * 0.5, Math.cos(angle) * r]}
|
||||
rotation={[rng() * 0.5, rng() * Math.PI, rng() * 0.5]}
|
||||
scale={[s * (0.8 + rng() * 0.4), s, s * (0.8 + rng() * 0.4)]}
|
||||
castShadow
|
||||
>
|
||||
<dodecahedronGeometry args={[1, 0]} />
|
||||
<meshStandardMaterial
|
||||
color={`rgb(${grey},${grey - 5},${grey - 10})`}
|
||||
roughness={0.95}
|
||||
metalness={0.05}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Mountain ─────────────────────────────────────────────────────────────────
|
||||
const Mountain = ({
|
||||
position,
|
||||
scale,
|
||||
seed,
|
||||
hasSnow,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale: number;
|
||||
seed: number;
|
||||
hasSnow: boolean;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 17 + 5);
|
||||
const peakH = 0.55 + rng() * 0.35;
|
||||
const baseR = 0.38 + rng() * 0.18;
|
||||
const sides = 5 + Math.floor(rng() * 3);
|
||||
const rockH = Math.floor(20 + rng() * 30);
|
||||
|
||||
const bodyGeo = useMemo(
|
||||
() => new THREE.ConeGeometry(baseR, peakH, sides),
|
||||
[baseR, peakH, sides],
|
||||
);
|
||||
const capGeo = useMemo(
|
||||
() => new THREE.ConeGeometry(baseR * 0.28, peakH * 0.28, sides),
|
||||
[baseR, peakH, sides],
|
||||
);
|
||||
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
<mesh geometry={bodyGeo} position={[0, peakH / 2, 0]} castShadow>
|
||||
<meshStandardMaterial
|
||||
color={`hsl(${rockH}, 18%, ${28 + Math.floor(rng() * 14)}%)`}
|
||||
roughness={0.92}
|
||||
/>
|
||||
</mesh>
|
||||
{hasSnow && (
|
||||
<mesh geometry={capGeo} position={[0, peakH * 0.78, 0]} castShadow>
|
||||
<meshStandardMaterial color="#f0f4ff" roughness={0.6} />
|
||||
</mesh>
|
||||
)}
|
||||
{/* Lava glow for volcanic */}
|
||||
{!hasSnow && rng() > 0.55 && (
|
||||
<mesh position={[0, peakH * 0.92, 0]}>
|
||||
<sphereGeometry args={[baseR * 0.12, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color="#ff4500"
|
||||
emissive="#ff4500"
|
||||
emissiveIntensity={1.5}
|
||||
transparent
|
||||
opacity={0.85}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Beach strip ─────────────────────────────────────────────────────────────
|
||||
const BeachStrip = ({ seed, radius }: { seed: number; radius: number }) => {
|
||||
const rng = makeRng(seed * 29 + 13);
|
||||
const arcStart = rng() * Math.PI * 2;
|
||||
const arcLen = 0.6 + rng() * 1.2;
|
||||
const segments = 18;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
const shape = new THREE.Shape();
|
||||
const r0 = radius * 0.78;
|
||||
const r1 = radius * 1.0;
|
||||
shape.moveTo(Math.cos(arcStart) * r0, Math.sin(arcStart) * r0);
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const a = arcStart + (i / segments) * arcLen;
|
||||
const jitter = 0.92 + Math.sin(i * 7.3) * 0.08;
|
||||
shape.lineTo(Math.cos(a) * r1 * jitter, Math.sin(a) * r1 * jitter);
|
||||
}
|
||||
for (let i = segments; i >= 0; i--) {
|
||||
const a = arcStart + (i / segments) * arcLen;
|
||||
shape.lineTo(Math.cos(a) * r0, Math.sin(a) * r0);
|
||||
}
|
||||
shape.closePath();
|
||||
const g = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: 0.06,
|
||||
bevelEnabled: false,
|
||||
});
|
||||
g.rotateX(-Math.PI / 2);
|
||||
g.translate(0, 0.02, 0);
|
||||
return g;
|
||||
}, [seed, radius]);
|
||||
|
||||
return (
|
||||
<mesh geometry={geo} receiveShadow>
|
||||
<meshStandardMaterial color="#e8d5a3" roughness={0.95} />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Lagoon ───────────────────────────────────────────────────────────────────
|
||||
const Lagoon = ({ seed }: { seed: number }) => {
|
||||
const rng = makeRng(seed * 61 + 19);
|
||||
const r = 0.22 + rng() * 0.18;
|
||||
const ox = (rng() - 0.5) * 0.5;
|
||||
const oz = (rng() - 0.5) * 0.5;
|
||||
// Compute the ellipse y-radius using the RNG (mirrors previous ellipseGeometry usage)
|
||||
const ry = r * (0.7 + rng() * 0.3);
|
||||
const meshRef = useRef<THREE.Mesh>(null!);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (meshRef.current) {
|
||||
(
|
||||
meshRef.current.material as THREE.MeshStandardMaterial
|
||||
).emissiveIntensity = 0.18 + Math.sin(clock.elapsedTime * 1.1) * 0.06;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
rotation-x={-Math.PI / 2}
|
||||
position={[ox, 0.04, oz]}
|
||||
// Use a circleGeometry and non-uniform scale to create an ellipse
|
||||
scale={[r, ry, 1]}
|
||||
>
|
||||
<circleGeometry args={[1, 24]} />
|
||||
<meshStandardMaterial
|
||||
color="#1a9fc0"
|
||||
emissive="#0bb4d4"
|
||||
emissiveIntensity={0.2}
|
||||
roughness={0.08}
|
||||
metalness={0.3}
|
||||
transparent
|
||||
opacity={0.82}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Flower patch ─────────────────────────────────────────────────────────────
|
||||
const FlowerPatch = ({
|
||||
position,
|
||||
seed,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
seed: number;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 43 + 17);
|
||||
const count = 3 + Math.floor(rng() * 4);
|
||||
const COLORS = [
|
||||
"#ff6b9d",
|
||||
"#ffd93d",
|
||||
"#ff8c42",
|
||||
"#c77dff",
|
||||
"#ff4d6d",
|
||||
"#ff9f1c",
|
||||
];
|
||||
return (
|
||||
<group position={position}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const angle = rng() * Math.PI * 2;
|
||||
const r = rng() * 0.18;
|
||||
const color = COLORS[Math.floor(rng() * COLORS.length)];
|
||||
return (
|
||||
<group
|
||||
key={i}
|
||||
position={[Math.sin(angle) * r, 0, Math.cos(angle) * r]}
|
||||
>
|
||||
<mesh position={[0, 0.06, 0]}>
|
||||
<cylinderGeometry args={[0.008, 0.008, 0.12, 4]} />
|
||||
<meshStandardMaterial color="#3a7d44" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.13, 0]}>
|
||||
<sphereGeometry args={[0.038, 6, 6]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
roughness={0.7}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.15}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Island hut ───────────────────────────────────────────────────────────────
|
||||
const IslandHut = ({
|
||||
position,
|
||||
scale,
|
||||
seed,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale: number;
|
||||
seed: number;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 73 + 23);
|
||||
const roofColor = rng() > 0.5 ? "#c17f3a" : "#8b5e3c";
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
<mesh position={[0, 0.1, 0]} castShadow>
|
||||
<boxGeometry args={[0.22, 0.2, 0.22]} />
|
||||
<meshStandardMaterial color="#d4a96a" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.25, 0]} castShadow>
|
||||
<coneGeometry args={[0.18, 0.16, 4]} />
|
||||
<meshStandardMaterial color={roofColor} roughness={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.07, 0.112]}>
|
||||
<boxGeometry args={[0.06, 0.1, 0.01]} />
|
||||
<meshStandardMaterial color="#3d1c0c" roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Terrain orchestrator ─────────────────────────────────────────────────────
|
||||
const IslandTerrain = ({
|
||||
seed,
|
||||
isLocked,
|
||||
}: {
|
||||
seed: number;
|
||||
isLocked: boolean;
|
||||
}) => {
|
||||
const rng = makeRng(seed * 1000 + 42);
|
||||
|
||||
// Six archetypes: 0=tropical 1=volcanic 2=snowy 3=savanna 4=jungle 5=desert
|
||||
const archetype = Math.floor(rng() * 6);
|
||||
const hasMountain = archetype === 1 || archetype === 2 || rng() > 0.55;
|
||||
const hasSnow = archetype === 2;
|
||||
const palmCount =
|
||||
archetype === 0 || archetype === 3
|
||||
? 2 + Math.floor(rng() * 3)
|
||||
: Math.floor(rng() * 2);
|
||||
const pineCount =
|
||||
archetype === 4 ? 2 + Math.floor(rng() * 3) : Math.floor(rng() * 2);
|
||||
const rockCount = 1 + Math.floor(rng() * 3);
|
||||
const hasBeach = archetype !== 2 && rng() > 0.3;
|
||||
const hasLagoon = rng() > 0.6;
|
||||
const flowerCount =
|
||||
archetype === 4 || archetype === 0
|
||||
? 2 + Math.floor(rng() * 3)
|
||||
: Math.floor(rng() * 2);
|
||||
const hasHut = rng() > 0.65;
|
||||
|
||||
if (isLocked) return null;
|
||||
|
||||
// Each call advances rng to a stable position for that feature
|
||||
const spot = (spread = 0.65): [number, number, number] => {
|
||||
const a = rng() * Math.PI * 2;
|
||||
const r = rng() * spread;
|
||||
return [Math.sin(a) * r, 0, Math.cos(a) * r];
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={[0, 0.3, 0]}>
|
||||
{hasBeach && <BeachStrip seed={seed + 1} radius={0.9} />}
|
||||
{hasLagoon && <Lagoon seed={seed + 2} />}
|
||||
|
||||
{hasMountain && (
|
||||
<Mountain
|
||||
position={spot(0.45)}
|
||||
scale={0.7 + rng() * 0.5}
|
||||
seed={seed + 3}
|
||||
hasSnow={hasSnow}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Array.from({ length: palmCount }, (_, i) => (
|
||||
<PalmTree
|
||||
key={`palm-${i}`}
|
||||
position={spot(0.65)}
|
||||
scale={0.8 + rng() * 0.5}
|
||||
seed={seed + 10 + i}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: pineCount }, (_, i) => (
|
||||
<PineTree
|
||||
key={`pine-${i}`}
|
||||
position={spot(0.62)}
|
||||
scale={0.75 + rng() * 0.45}
|
||||
seed={seed + 20 + i}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: rockCount }, (_, i) => (
|
||||
<RockCluster
|
||||
key={`rock-${i}`}
|
||||
position={spot(0.7)}
|
||||
scale={0.7 + rng() * 0.6}
|
||||
seed={seed + 30 + i}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: flowerCount }, (_, i) => (
|
||||
<FlowerPatch
|
||||
key={`flower-${i}`}
|
||||
position={spot(0.6)}
|
||||
seed={seed + 40 + i}
|
||||
/>
|
||||
))}
|
||||
{hasHut && (
|
||||
<IslandHut
|
||||
position={spot(0.4)}
|
||||
scale={0.9 + rng() * 0.3}
|
||||
seed={seed + 50}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Current island marker ────────────────────────────────────────────────────
|
||||
const CurrentMarker = ({ accent }: { accent: string }) => {
|
||||
const groupRef = useRef<THREE.Group>(null!);
|
||||
const innerRef = useRef<THREE.Group>(null!);
|
||||
const ringRef = useRef<THREE.Mesh>(null!);
|
||||
const beamRef = useRef<THREE.Mesh>(null!);
|
||||
|
||||
const accentColor = useMemo(() => new THREE.Color(accent), [accent]);
|
||||
const goldColor = useMemo(() => new THREE.Color("#fbbf24"), []);
|
||||
const torusGeo = useMemo(
|
||||
() => new THREE.TorusGeometry(0.38, 0.045, 12, 48),
|
||||
[],
|
||||
);
|
||||
const spokeGeo = useMemo(
|
||||
() => new THREE.CylinderGeometry(0.022, 0.022, 0.6, 8),
|
||||
[],
|
||||
);
|
||||
const orbGeo = useMemo(() => new THREE.SphereGeometry(0.1, 16, 16), []);
|
||||
const beamGeo = useMemo(
|
||||
() => new THREE.CylinderGeometry(0.018, 0.05, 1.6, 8, 1, true),
|
||||
[],
|
||||
);
|
||||
const haloGeo = useMemo(() => new THREE.CircleGeometry(0.55, 40), []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const t = clock.elapsedTime;
|
||||
if (groupRef.current)
|
||||
groupRef.current.position.y = 2.1 + Math.sin(t * 1.4) * 0.18;
|
||||
if (innerRef.current) innerRef.current.rotation.y = t * 0.55;
|
||||
if (ringRef.current)
|
||||
(
|
||||
ringRef.current.material as THREE.MeshStandardMaterial
|
||||
).emissiveIntensity = 0.6 + Math.sin(t * 2.2) * 0.35;
|
||||
if (beamRef.current)
|
||||
(beamRef.current.material as THREE.MeshStandardMaterial).opacity =
|
||||
0.18 + Math.sin(t * 1.8 + 1) * 0.1;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={[0, 2.1, 0]}>
|
||||
<mesh ref={beamRef} geometry={beamGeo} position={[0, -0.5, 0]}>
|
||||
<meshStandardMaterial
|
||||
color={goldColor}
|
||||
emissive={goldColor}
|
||||
emissiveIntensity={0.8}
|
||||
transparent
|
||||
opacity={0.22}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<group ref={innerRef}>
|
||||
<mesh geometry={haloGeo} rotation-x={-Math.PI / 2}>
|
||||
<meshStandardMaterial
|
||||
color={accentColor}
|
||||
emissive={accentColor}
|
||||
emissiveIntensity={0.25}
|
||||
transparent
|
||||
opacity={0.12}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh ref={ringRef} geometry={torusGeo} rotation-x={Math.PI / 2}>
|
||||
<meshStandardMaterial
|
||||
color={goldColor}
|
||||
emissive={goldColor}
|
||||
emissiveIntensity={0.7}
|
||||
roughness={0.2}
|
||||
metalness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
{[0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2].map((angle, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
geometry={spokeGeo}
|
||||
position={[Math.sin(angle) * 0.2, 0, Math.cos(angle) * 0.2]}
|
||||
rotation-z={angle === 0 || angle === Math.PI ? 0 : Math.PI / 2}
|
||||
rotation-y={angle}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={goldColor}
|
||||
emissive={goldColor}
|
||||
emissiveIntensity={0.4}
|
||||
roughness={0.3}
|
||||
metalness={0.7}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
{[
|
||||
{ pos: [0, 0.38, 0] as [number, number, number], scale: 1.0 },
|
||||
{ pos: [0, -0.38, 0] as [number, number, number], scale: 0.7 },
|
||||
{ pos: [0.38, 0, 0] as [number, number, number], scale: 0.7 },
|
||||
{ pos: [-0.38, 0, 0] as [number, number, number], scale: 0.7 },
|
||||
].map(({ pos, scale }, i) => (
|
||||
<mesh key={i} position={pos} scale={scale}>
|
||||
<octahedronGeometry args={[0.09, 0]} />
|
||||
<meshStandardMaterial
|
||||
color={i === 0 ? "#ffffff" : goldColor}
|
||||
emissive={i === 0 ? "#ffffff" : goldColor}
|
||||
emissiveIntensity={i === 0 ? 1.2 : 0.6}
|
||||
roughness={0.1}
|
||||
metalness={0.9}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
<mesh geometry={orbGeo}>
|
||||
<meshStandardMaterial
|
||||
color={accentColor}
|
||||
emissive={accentColor}
|
||||
emissiveIntensity={1.2}
|
||||
roughness={0.1}
|
||||
metalness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
<Billboard>
|
||||
<Text
|
||||
position={[0, 0.7, 0]}
|
||||
fontSize={0.18}
|
||||
color="#fbbf24"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.018}
|
||||
outlineColor="#000000"
|
||||
letterSpacing={0.08}
|
||||
>
|
||||
YOU ARE HERE
|
||||
</Text>
|
||||
</Billboard>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Island3D ─────────────────────────────────────────────────────────────────
|
||||
export const Island3D = ({
|
||||
node,
|
||||
position,
|
||||
@ -103,51 +679,44 @@ export const Island3D = ({
|
||||
onTap,
|
||||
onClaim,
|
||||
index,
|
||||
isCurrent = false,
|
||||
modalOpen = false,
|
||||
}: Island3DProps) => {
|
||||
const topMeshRef = useRef<THREE.Mesh>(null!);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const status = (node.status ?? "LOCKED") as keyof typeof PALETTE;
|
||||
const status = (node.status?.toUpperCase() ??
|
||||
"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 { topGeo, cliffGeo } = useMemo(() => {
|
||||
const topShape = makeIslandShape(seed, 1.0);
|
||||
const topGeo = new THREE.ExtrudeGeometry(topShape, {
|
||||
depth: 0.3,
|
||||
depth: 0.32,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.1,
|
||||
bevelSize: 0.07,
|
||||
bevelSegments: 4,
|
||||
bevelThickness: 0.12,
|
||||
bevelSize: 0.08,
|
||||
bevelSegments: 5,
|
||||
});
|
||||
// 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 cliffShape = makeIslandShape(seed, 1.14);
|
||||
const cliffGeo = new THREE.ExtrudeGeometry(cliffShape, {
|
||||
depth: 0.55,
|
||||
depth: 0.58,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.05,
|
||||
bevelThickness: 0.06,
|
||||
bevelSize: 0.04,
|
||||
bevelSegments: 2,
|
||||
});
|
||||
cliffGeo.rotateX(-Math.PI / 2);
|
||||
cliffGeo.translate(0, -0.3, 0); // sit directly below top plateau
|
||||
|
||||
cliffGeo.translate(0, -0.3, 0);
|
||||
return { topGeo, cliffGeo };
|
||||
}, [index]);
|
||||
}, [seed]);
|
||||
|
||||
// ── Colours ───────────────────────────────────────────────────────────────
|
||||
const pal = PALETTE[status];
|
||||
const topColor = isActive
|
||||
? terrain.l
|
||||
@ -160,16 +729,15 @@ export const Island3D = ({
|
||||
? 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);
|
||||
topMeshRef.current.scale.setScalar(
|
||||
curr + (target - curr) * (1 - Math.exp(-14 * dt)),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Emoji ────────────────────────────────────────────────────────────────
|
||||
const emoji = isLocked
|
||||
? "🔒"
|
||||
: isClaimable
|
||||
@ -177,12 +745,10 @@ export const Island3D = ({
|
||||
: 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 (
|
||||
@ -206,29 +772,32 @@ export const Island3D = ({
|
||||
if (!isLocked) onTap(node);
|
||||
}}
|
||||
>
|
||||
{/* ── Top plateau — solid, opaque ────────────────────────────────── */}
|
||||
{/* Top plateau */}
|
||||
<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}
|
||||
roughness={0.6}
|
||||
metalness={0.04}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* ── Cliff body — solid, darker shade ──────────────────────────── */}
|
||||
{/* Cliff */}
|
||||
<mesh geometry={cliffGeo} castShadow>
|
||||
<meshStandardMaterial
|
||||
color={sideColor}
|
||||
roughness={0.82}
|
||||
roughness={0.88}
|
||||
metalness={0.02}
|
||||
emissive={pal.emissive}
|
||||
emissiveIntensity={pal.ei * 0.25}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* ── Water-level glow ring ──────────────────────────────────────── */}
|
||||
{/* Procedural terrain */}
|
||||
<IslandTerrain seed={seed} isLocked={isLocked} />
|
||||
|
||||
{/* Water-level glow ring */}
|
||||
{!isLocked && (
|
||||
<mesh rotation-x={-Math.PI / 2} position-y={-0.86}>
|
||||
<ringGeometry args={[1.3, 1.7, 48]} />
|
||||
@ -244,7 +813,7 @@ export const Island3D = ({
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* ── Claimable sparkles + top ring ─────────────────────────────── */}
|
||||
{/* Claimable sparkles */}
|
||||
{isClaimable && (
|
||||
<>
|
||||
<Sparkles
|
||||
@ -269,7 +838,7 @@ export const Island3D = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Hover ring ────────────────────────────────────────────────── */}
|
||||
{/* Hover ring */}
|
||||
{hovered && !isLocked && (
|
||||
<mesh rotation-x={-Math.PI / 2} position-y={0.06}>
|
||||
<ringGeometry args={[1.0, 1.65, 40]} />
|
||||
@ -285,7 +854,7 @@ export const Island3D = ({
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* ── Billboard emoji above island ───────────────────────────────── */}
|
||||
{/* Emoji */}
|
||||
<Billboard>
|
||||
<Text
|
||||
position={[0, 1.3, 0]}
|
||||
@ -297,7 +866,10 @@ export const Island3D = ({
|
||||
</Text>
|
||||
</Billboard>
|
||||
|
||||
{/* ── Info card ─────────────────────────────────────────────────── */}
|
||||
{/* Current marker */}
|
||||
{isCurrent && <CurrentMarker accent={accent} />}
|
||||
|
||||
{/* Info card */}
|
||||
<Html
|
||||
position={[C.posX, 0.1, 0]}
|
||||
transform={false}
|
||||
@ -352,27 +924,25 @@ export const Island3D = ({
|
||||
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",
|
||||
color: isClaimable
|
||||
? "#fde68a"
|
||||
: isLocked
|
||||
? "rgba(255,255,255,0.28)"
|
||||
: "rgba(255,255,255,0.93)",
|
||||
}}
|
||||
>
|
||||
{node.name ?? "—"}
|
||||
</div>
|
||||
|
||||
{/* XP + status */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@ -399,6 +969,9 @@ export const Island3D = ({
|
||||
style={{
|
||||
fontSize: C.statusSize,
|
||||
fontWeight: 800,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
whiteSpace: "nowrap",
|
||||
color: isClaimable
|
||||
? "#fbbf24"
|
||||
: isCompleted
|
||||
@ -406,9 +979,6 @@ export const Island3D = ({
|
||||
: isActive
|
||||
? "rgba(255,255,255,0.5)"
|
||||
: "rgba(255,255,255,0.18)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{isClaimable
|
||||
@ -421,7 +991,6 @@ export const Island3D = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isActive && node.req_target > 0 && (
|
||||
<div
|
||||
style={{
|
||||
@ -444,7 +1013,6 @@ export const Island3D = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claim button */}
|
||||
{isClaimable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@ -1,16 +1,76 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { BlockMath, InlineMath } from "react-katex";
|
||||
|
||||
// ─── Error boundary ───────────────────────────────────────────────────────────
|
||||
// react-katex throws synchronously during render for invalid LaTeX, so a class
|
||||
// error boundary is the only reliable way to catch it.
|
||||
|
||||
interface MathErrorBoundaryProps {
|
||||
raw: string; // the original LaTeX string, shown as fallback
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface MathErrorBoundaryState {
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export class MathErrorBoundary extends Component<
|
||||
MathErrorBoundaryProps,
|
||||
MathErrorBoundaryState
|
||||
> {
|
||||
state: MathErrorBoundaryState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): MathErrorBoundaryState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<span
|
||||
title={`Could not render: ${this.props.raw}`}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 4,
|
||||
padding: "0 4px",
|
||||
color: "#f87171",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
{this.props.raw}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Renderer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const renderQuestionText = (text: string) => {
|
||||
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
||||
if (!text) return null;
|
||||
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/gs);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (part.startsWith("$$")) {
|
||||
return <BlockMath key={index}>{part.slice(2, -2)}</BlockMath>;
|
||||
const latex = part.slice(2, -2);
|
||||
return (
|
||||
<MathErrorBoundary key={index} raw={part}>
|
||||
<BlockMath>{latex}</BlockMath>
|
||||
</MathErrorBoundary>
|
||||
);
|
||||
}
|
||||
if (part.startsWith("$")) {
|
||||
return <InlineMath key={index}>{part.slice(1, -1)}</InlineMath>;
|
||||
const latex = part.slice(1, -1);
|
||||
return (
|
||||
<MathErrorBoundary key={index} raw={part}>
|
||||
<InlineMath>{latex}</InlineMath>
|
||||
</MathErrorBoundary>
|
||||
);
|
||||
}
|
||||
return <span key={index}>{part}</span>;
|
||||
})}
|
||||
|
||||
@ -20,14 +20,6 @@ const VW = 420;
|
||||
const TOP_PAD = 80;
|
||||
const ROW_H = 520; // vertical step per island (increased for more separation)
|
||||
|
||||
const ISLAND_SCALE = 1.2;
|
||||
const LAND_H_BASE = 40;
|
||||
const LAND_H = LAND_H_BASE * ISLAND_SCALE;
|
||||
|
||||
const CARD_W = 130;
|
||||
const CARD_H = 170;
|
||||
const CARD_H_CLAIMABLE = 235;
|
||||
|
||||
// ─── Seeded RNG ───────────────────────────────────────────────────────────────
|
||||
const mkRng = (seed: number) => {
|
||||
let s = seed >>> 0;
|
||||
@ -108,23 +100,6 @@ const generateIslandPositions = (
|
||||
return positions;
|
||||
};
|
||||
|
||||
const svgHeight = (positions: { x: number; y: number }[]) => {
|
||||
if (!positions.length) return 600;
|
||||
return (
|
||||
Math.max(...positions.map((p) => p.y)) + TOP_PAD + CARD_H_CLAIMABLE + LAND_H
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Island shapes ────────────────────────────────────────────────────────────
|
||||
const SHAPES = [
|
||||
`<ellipse cx="0" cy="0" rx="57" ry="33"/>`,
|
||||
`<polygon points="0,-38 28,-14 48,10 40,33 22,38 -22,38 -40,33 -48,10 -28,-14"/>`,
|
||||
`<ellipse cx="0" cy="5" rx="62" ry="26"/>`,
|
||||
`<polygon points="0,-38 20,-14 50,-8 32,12 42,36 16,24 0,38 -16,24 -42,36 -32,12 -50,-8 -20,-14"/>`,
|
||||
`<path d="M-50,0 C-50,-34 -20,-38 0,-36 C22,-34 48,-18 50,4 C52,24 36,30 18,24 C6,20 4,10 10,4 C16,-4 26,-4 28,4 C30,12 22,18 12,16 C-4,10 -10,-8 0,-20 C12,-32 -30,-28 -50,0 Z"/>`,
|
||||
`<path d="M0,-38 C18,-38 44,-18 44,8 C44,28 26,38 0,38 C-26,38 -44,28 -44,8 C-44,-18 -18,-38 0,-38 Z"/>`,
|
||||
];
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
|
||||
@ -504,6 +479,127 @@ const STYLES = `
|
||||
.qmlp-arc-dot { width:7px; height:7px; border-radius:50%; background:#ef4444; box-shadow:0 0 7px #ef4444; flex-shrink:0; animation:qmDotBlink 1.4s ease-in-out infinite; }
|
||||
`;
|
||||
|
||||
const ERROR_STYLES = `
|
||||
.qme-bg {
|
||||
position:absolute; inset:0;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 100%, #023e6e 0%, #011a38 50%, #020b18 100%);
|
||||
z-index:0;
|
||||
}
|
||||
.qme-fog {
|
||||
position:absolute; pointer-events:none; z-index:1;
|
||||
border-radius:50%; filter:blur(60px);
|
||||
}
|
||||
.qme-fog1 {
|
||||
width:500px; height:200px; left:-80px; top:30%;
|
||||
background:rgba(0,119,182,0.12);
|
||||
animation: qmeFogDrift 18s ease-in-out infinite alternate;
|
||||
}
|
||||
.qme-fog2 {
|
||||
width:400px; height:160px; right:-60px; top:50%;
|
||||
background:rgba(0,96,145,0.09);
|
||||
animation: qmeFogDrift 22s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
@keyframes qmeFogDrift { 0%{transform:translate(0,0);} 100%{transform:translate(40px,-20px);} }
|
||||
|
||||
.qme-debris {
|
||||
position:absolute; border-radius:2px; pointer-events:none; z-index:2;
|
||||
background:rgba(100,65,35,0.55);
|
||||
}
|
||||
.qme-debris-0 { width:22px;height:5px; left:12%; top:58%; animation:qmeDebrisBob 5.1s ease-in-out infinite; }
|
||||
.qme-debris-1 { width:14px;height:4px; left:78%; top:62%; animation:qmeDebrisBob 4.3s ease-in-out infinite 0.7s; }
|
||||
.qme-debris-2 { width:8px; height:8px; border-radius:50%; left:22%; top:72%; background:rgba(251,191,36,0.18); animation:qmeDebrisBob 6.2s ease-in-out infinite 1.1s; }
|
||||
.qme-debris-3 { width:18px;height:4px; left:64%; top:54%; animation:qmeDebrisBob 4.8s ease-in-out infinite 0.3s; }
|
||||
.qme-debris-4 { width:6px; height:6px; border-radius:50%; left:85%; top:44%; background:rgba(0,180,216,0.25); animation:qmeDebrisBob 3.9s ease-in-out infinite 1.8s; }
|
||||
.qme-debris-5 { width:10px;height:3px; left:8%; top:80%; animation:qmeDebrisBob 5.5s ease-in-out infinite 0.5s; }
|
||||
.qme-debris-6 { width:24px;height:5px; left:48%; top:76%; animation:qmeDebrisBob 6.7s ease-in-out infinite 2.2s; }
|
||||
.qme-debris-7 { width:7px; height:7px; border-radius:50%; left:35%; top:45%; background:rgba(100,65,35,0.3); animation:qmeDebrisBob 4.1s ease-in-out infinite 1.4s; }
|
||||
@keyframes qmeDebrisBob { 0%,100%{transform:translateY(0) rotate(0deg);opacity:0.7;} 50%{transform:translateY(-9px) rotate(6deg);opacity:1;} }
|
||||
|
||||
.qme-stage {
|
||||
position:relative; z-index:10; display:flex; flex-direction:column; align-items:center;
|
||||
padding:1rem 2rem 3rem; max-width:380px; width:100%;
|
||||
animation:qmeStageIn 0.9s cubic-bezier(0.22,1,0.36,1) both;
|
||||
}
|
||||
@keyframes qmeStageIn { 0%{opacity:0;transform:translateY(28px);} 100%{opacity:1;transform:translateY(0);} }
|
||||
|
||||
.qme-ship-wrap {
|
||||
width:240px; margin-bottom:0.4rem;
|
||||
animation:qmeShipRock 5s ease-in-out infinite;
|
||||
transform-origin:center bottom;
|
||||
filter:drop-shadow(0 12px 40px rgba(0,80,160,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.8));
|
||||
}
|
||||
@keyframes qmeShipRock { 0%,100%{transform:rotate(-2.5deg) translateY(0);} 50%{transform:rotate(2.5deg) translateY(-4px);} }
|
||||
.qme-ship-svg { width:100%; height:auto; display:block; }
|
||||
.qme-mast { animation:qmeMastSway 7s ease-in-out infinite; transform-origin:130px 148px; }
|
||||
@keyframes qmeMastSway { 0%,100%{transform:rotate(-1deg);} 50%{transform:rotate(1.5deg);} }
|
||||
.qme-hull { animation:qmeHullSettle 5s ease-in-out infinite 0.3s; transform-origin:120px 145px; }
|
||||
@keyframes qmeHullSettle { 0%,100%{transform:rotate(-1.5deg);} 50%{transform:rotate(1deg);} }
|
||||
|
||||
.qme-content {
|
||||
text-align:center; display:flex; flex-direction:column; align-items:center; gap:0.55rem;
|
||||
animation:qmeStageIn 1s cubic-bezier(0.22,1,0.36,1) 0.12s both;
|
||||
}
|
||||
|
||||
.qme-eyebrow {
|
||||
font-family:'Cinzel',serif; font-size:0.58rem; font-weight:700;
|
||||
letter-spacing:0.28em; text-transform:uppercase; color:rgba(251,191,36,0.5);
|
||||
}
|
||||
|
||||
.qme-title {
|
||||
font-family:'Sorts Mill Goudy',serif; font-size:2.4rem; font-weight:900; margin:0;
|
||||
color:#ffffff; letter-spacing:0.02em;
|
||||
text-shadow:0 0 40px rgba(0,150,220,0.45), 0 2px 20px rgba(0,0,0,0.8);
|
||||
line-height:1.05;
|
||||
}
|
||||
|
||||
.qme-subtitle {
|
||||
font-family:'Nunito Sans',sans-serif; font-size:0.82rem; font-weight:600; margin:0;
|
||||
color:rgba(255,255,255,0.38); max-width:260px; line-height:1.5;
|
||||
}
|
||||
|
||||
.qme-error-badge {
|
||||
display:flex; align-items:center; gap:0.45rem;
|
||||
background:rgba(239,68,68,0.08); border:1px solid rgba(239,68,68,0.25);
|
||||
border-radius:100px; padding:0.32rem 0.85rem;
|
||||
max-width:100%; overflow:hidden;
|
||||
}
|
||||
.qme-error-dot {
|
||||
width:6px; height:6px; border-radius:50%; background:#ef4444;
|
||||
box-shadow:0 0 8px #ef4444; flex-shrink:0;
|
||||
animation:qmDotBlink 1.4s ease-in-out infinite;
|
||||
}
|
||||
.qme-error-text {
|
||||
font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:700;
|
||||
color:rgba(239,68,68,0.85); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
}
|
||||
|
||||
.qme-retry-btn {
|
||||
position:relative; overflow:hidden;
|
||||
margin-top:0.35rem; padding:0.7rem 2rem;
|
||||
background:linear-gradient(135deg,#0077b6,#023e8a);
|
||||
border:1px solid rgba(0,180,216,0.45); border-radius:100px; cursor:pointer;
|
||||
font-family:'Sorts Mill Goudy',serif; font-size:0.88rem; font-weight:700; letter-spacing:0.06em;
|
||||
color:white;
|
||||
box-shadow:0 4px 0 rgba(0,30,80,0.6), 0 6px 28px rgba(0,100,200,0.25);
|
||||
transition:all 0.14s ease;
|
||||
}
|
||||
.qme-retry-btn:hover { transform:translateY(-2px); box-shadow:0 6px 0 rgba(0,30,80,0.6), 0 10px 32px rgba(0,120,220,0.35); }
|
||||
.qme-retry-btn:active { transform:translateY(1px); box-shadow:0 2px 0 rgba(0,30,80,0.6); }
|
||||
.qme-btn-wake {
|
||||
position:absolute; inset:0; border-radius:100px;
|
||||
background:linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.12) 50%, transparent 100%);
|
||||
transform:translateX(-100%);
|
||||
animation:qmeBtnSheen 3.5s ease-in-out infinite 1s;
|
||||
}
|
||||
@keyframes qmeBtnSheen { 0%,100%{transform:translateX(-100%);} 40%,60%{transform:translateX(100%);} }
|
||||
.qme-btn-label { position:relative; z-index:1; }
|
||||
|
||||
.qme-hint {
|
||||
font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:600; margin:0;
|
||||
color:rgba(255,255,255,0.18); max-width:240px; line-height:1.5; font-style:italic;
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Arc theme ────────────────────────────────────────────────────────────────
|
||||
export interface ArcTheme {
|
||||
accent: string;
|
||||
@ -936,17 +1032,16 @@ const IslandScene = ({
|
||||
modalOpen,
|
||||
}: MapContentProps) => {
|
||||
const nodeCount = arc.nodes.length;
|
||||
const tropicalTerrain = {
|
||||
l: "#3ecf6a",
|
||||
m: "#1fa84a",
|
||||
d: "#157a36",
|
||||
s: "#03045e",
|
||||
};
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...arc.nodes].sort((a, b) => a.sequence_order - b.sequence_order),
|
||||
[arc.nodes],
|
||||
);
|
||||
|
||||
const currentIdx = sorted.findIndex(
|
||||
(n) => n.status === "ACTIVE" || n.status === "CLAIMABLE",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WaterPlane />
|
||||
@ -974,9 +1069,10 @@ const IslandScene = ({
|
||||
index={i}
|
||||
position={[x3, 0, z3]}
|
||||
accent={theme.accent}
|
||||
terrain={tropicalTerrain}
|
||||
terrain={theme.terrain}
|
||||
onTap={onNodeTap}
|
||||
onClaim={onClaim}
|
||||
isCurrent={i === currentIdx} // ← add this
|
||||
modalOpen={modalOpen}
|
||||
/>
|
||||
);
|
||||
@ -1583,45 +1679,356 @@ export const QuestMap = () => {
|
||||
style={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<style>{STYLES}</style>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontFamily: "'Nunito',sans-serif",
|
||||
}}
|
||||
<style>
|
||||
{STYLES}
|
||||
{ERROR_STYLES}
|
||||
</style>
|
||||
|
||||
{/* Deep-sea atmospheric background */}
|
||||
<div className="qme-bg" />
|
||||
<div className="qme-fog qme-fog1" />
|
||||
<div className="qme-fog qme-fog2" />
|
||||
|
||||
{/* Floating wreckage debris */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className={`qme-debris qme-debris-${i}`} />
|
||||
))}
|
||||
|
||||
<div className="qme-stage">
|
||||
{/* ── Shipwreck SVG illustration ── */}
|
||||
<div className="qme-ship-wrap">
|
||||
<svg
|
||||
className="qme-ship-svg"
|
||||
viewBox="0 0 240 180"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<div style={{ fontSize: "2.5rem", marginBottom: "1rem" }}>🌊</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 800,
|
||||
color: "#ef4444",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
{/* Ocean surface rings behind the wreck */}
|
||||
<ellipse
|
||||
cx="120"
|
||||
cy="145"
|
||||
rx="100"
|
||||
ry="18"
|
||||
fill="rgba(3,4,94,0.6)"
|
||||
/>
|
||||
<path
|
||||
d="M20 145 Q45 132 70 145 Q95 158 120 145 Q145 132 170 145 Q195 158 220 145"
|
||||
stroke="#0077b6"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
opacity="0.5"
|
||||
strokeDasharray="6 4"
|
||||
>
|
||||
{fetchError ?? "No quest data found"}
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="0;20"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path
|
||||
d="M10 155 Q40 143 68 155 Q96 167 124 155 Q152 143 180 155 Q208 167 230 155"
|
||||
stroke="#0096c7"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
opacity="0.35"
|
||||
strokeDasharray="5 5"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="20;0"
|
||||
dur="2.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
|
||||
{/* Broken mast — leaning left */}
|
||||
<g className="qme-mast">
|
||||
<line
|
||||
x1="130"
|
||||
y1="148"
|
||||
x2="88"
|
||||
y2="62"
|
||||
stroke="#6b4226"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="88"
|
||||
y1="62"
|
||||
x2="52"
|
||||
y2="44"
|
||||
stroke="#6b4226"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* Shredded sail */}
|
||||
<path
|
||||
d="M88 62 Q74 75 64 92 Q78 88 94 78 Z"
|
||||
fill="rgba(200,160,80,0.35)"
|
||||
stroke="rgba(200,160,80,0.5)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d="M88 62 Q98 72 104 84 Q96 80 88 62 Z"
|
||||
fill="rgba(200,160,80,0.2)"
|
||||
stroke="rgba(200,160,80,0.3)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Torn pirate flag */}
|
||||
<path d="M52 44 L72 51 L58 61 Z" fill="rgba(239,68,68,0.7)" />
|
||||
<path
|
||||
d="M58 61 Q65 57 72 51"
|
||||
stroke="rgba(239,68,68,0.5)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeDasharray="3 2"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="0;10"
|
||||
dur="1.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
|
||||
{/* Hull — cracked, half-submerged */}
|
||||
<g className="qme-hull">
|
||||
<path
|
||||
d="M58 130 Q72 110 100 108 Q128 106 152 112 Q172 116 174 130 Q160 148 120 152 Q80 156 58 130 Z"
|
||||
fill="#3d1c0c"
|
||||
stroke="#6b4226"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/* Crack lines */}
|
||||
<path
|
||||
d="M100 108 L96 128 L104 140"
|
||||
stroke="rgba(0,0,0,0.6)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M138 110 L142 132"
|
||||
stroke="rgba(0,0,0,0.5)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Porthole */}
|
||||
<circle
|
||||
cx="118"
|
||||
cy="128"
|
||||
r="7"
|
||||
fill="#1a0900"
|
||||
stroke="#6b4226"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<circle
|
||||
cx="118"
|
||||
cy="128"
|
||||
r="4"
|
||||
fill="rgba(251,191,36,0.06)"
|
||||
stroke="rgba(251,191,36,0.15)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Water-line sheen */}
|
||||
<path
|
||||
d="M70 140 Q95 133 120 135 Q145 133 168 138"
|
||||
stroke="rgba(0,180,216,0.3)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Barnacles */}
|
||||
<circle cx="85" cy="133" r="2" fill="rgba(100,200,100,0.25)" />
|
||||
<circle cx="92" cy="140" r="1.5" fill="rgba(100,200,100,0.2)" />
|
||||
<circle cx="155" cy="130" r="2" fill="rgba(100,200,100,0.25)" />
|
||||
|
||||
{/* Floating planks */}
|
||||
<rect
|
||||
x="40"
|
||||
y="138"
|
||||
width="14"
|
||||
height="5"
|
||||
rx="2"
|
||||
fill="#4a2c10"
|
||||
opacity="0.8"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="0,0;0,-3;0,0"
|
||||
dur="3.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
x="185"
|
||||
y="141"
|
||||
width="10"
|
||||
height="4"
|
||||
rx="1.5"
|
||||
fill="#4a2c10"
|
||||
opacity="0.6"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="0,0;0,-2;0,0"
|
||||
dur="2.7s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
|
||||
{/* Half-submerged treasure chest */}
|
||||
<g opacity="0.9">
|
||||
<rect
|
||||
x="106"
|
||||
y="148"
|
||||
width="28"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill="#5c3d1a"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<rect
|
||||
x="106"
|
||||
y="148"
|
||||
width="28"
|
||||
height="8"
|
||||
rx="3"
|
||||
fill="#7a5228"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<rect
|
||||
x="117"
|
||||
y="153"
|
||||
width="6"
|
||||
height="5"
|
||||
rx="1"
|
||||
fill="#fbbf24"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<circle cx="120" cy="152" r="1" fill="white" opacity="0.7">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.7;0.1;0.7"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* Rising bubbles */}
|
||||
<circle
|
||||
cx="108"
|
||||
cy="130"
|
||||
r="2"
|
||||
fill="none"
|
||||
stroke="rgba(0,180,216,0.5)"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values="130;90"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
begin="0s"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.6;0"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
begin="0s"
|
||||
/>
|
||||
</circle>
|
||||
<circle
|
||||
cx="135"
|
||||
cy="125"
|
||||
r="1.5"
|
||||
fill="none"
|
||||
stroke="rgba(0,180,216,0.4)"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values="125;85"
|
||||
dur="2.5s"
|
||||
repeatCount="indefinite"
|
||||
begin="0.8s"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.5;0"
|
||||
dur="2.5s"
|
||||
repeatCount="indefinite"
|
||||
begin="0.8s"
|
||||
/>
|
||||
</circle>
|
||||
<circle
|
||||
cx="122"
|
||||
cy="132"
|
||||
r="1"
|
||||
fill="none"
|
||||
stroke="rgba(0,180,216,0.35)"
|
||||
strokeWidth="0.8"
|
||||
>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values="132;100"
|
||||
dur="2.1s"
|
||||
repeatCount="indefinite"
|
||||
begin="1.4s"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.4;0"
|
||||
dur="2.1s"
|
||||
repeatCount="indefinite"
|
||||
begin="1.4s"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Copy & actions */}
|
||||
<div className="qme-content">
|
||||
<div className="qme-eyebrow">⚓ SHIPWRECKED</div>
|
||||
<h1 className="qme-title">Lost at Sea</h1>
|
||||
<p className="qme-subtitle">
|
||||
{fetchError
|
||||
? "The charts couldn't be retrieved from the depths."
|
||||
: "Your quest map has sunk without a trace."}
|
||||
</p>
|
||||
<div className="qme-error-badge">
|
||||
<span className="qme-error-dot" />
|
||||
<span className="qme-error-text">
|
||||
{fetchError ?? "No quest data found"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="qme-retry-btn"
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: "1rem",
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "100px",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
background: "transparent",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "'Nunito',sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
<span className="qme-btn-wake" />
|
||||
<span className="qme-btn-label">⛵ Set Sail Again</span>
|
||||
</button>
|
||||
<p className="qme-hint">
|
||||
Your progress and treasures are safely stored in Davy Jones'
|
||||
vault.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -32,7 +32,10 @@ import type {
|
||||
SubmitAnswer,
|
||||
} from "../../../types/session";
|
||||
import { useAuthToken } from "../../../hooks/useAuthToken";
|
||||
import { renderQuestionText } from "../../../components/RenderQuestionText";
|
||||
import {
|
||||
MathErrorBoundary,
|
||||
renderQuestionText,
|
||||
} from "../../../components/RenderQuestionText";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -1205,6 +1208,19 @@ export const Test = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Add this above renderQuestionTextWithHighlights
|
||||
const SafeBlockMath = ({ latex }: { latex: string }) => (
|
||||
<MathErrorBoundary raw={`$$${latex}$$`}>
|
||||
<BlockMath>{latex}</BlockMath>
|
||||
</MathErrorBoundary>
|
||||
);
|
||||
|
||||
const SafeInlineMath = ({ latex }: { latex: string }) => (
|
||||
<MathErrorBoundary raw={`$${latex}$`}>
|
||||
<InlineMath>{latex}</InlineMath>
|
||||
</MathErrorBoundary>
|
||||
);
|
||||
|
||||
const renderQuestionTextWithHighlights = (
|
||||
text: string,
|
||||
highlights: HighlightRange[],
|
||||
@ -1259,7 +1275,7 @@ export const Test = () => {
|
||||
const hasOverlap = merged.some(
|
||||
(h) => h.end > pos && h.start < pos + len,
|
||||
);
|
||||
const node = <BlockMath key={index}>{inner}</BlockMath>;
|
||||
const node = <SafeBlockMath key={index} latex={inner} />;
|
||||
pos += len;
|
||||
if (!hasOverlap) return node;
|
||||
return (
|
||||
@ -1274,7 +1290,7 @@ export const Test = () => {
|
||||
const hasOverlap = merged.some(
|
||||
(h) => h.end > pos && h.start < pos + len,
|
||||
);
|
||||
const node = <InlineMath key={index}>{inner}</InlineMath>;
|
||||
const node = <SafeInlineMath key={index} latex={inner} />;
|
||||
pos += len;
|
||||
if (!hasOverlap) return node;
|
||||
return (
|
||||
@ -1684,12 +1700,13 @@ export const Test = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{currentQuestion?.context_image_url !== "NULL" && (
|
||||
{currentQuestion?.context_image_url &&
|
||||
currentQuestion.context_image_url !== "NULL" && (
|
||||
<div className="t-card p-6">
|
||||
<img
|
||||
src={currentQuestion?.context_image_url}
|
||||
alt="Context"
|
||||
className="w-full h-auto"
|
||||
src="https://placehold.co/600x400"
|
||||
alt="Question context"
|
||||
className="w-full h-auto rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -1735,7 +1752,18 @@ export const Test = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentQuestion?.context && (
|
||||
{currentQuestion?.context_image_url &&
|
||||
currentQuestion.context_image_url !== "NULL" && (
|
||||
<div className="t-card p-6">
|
||||
<img
|
||||
src="https://placehold.co/600x400"
|
||||
alt="Question context"
|
||||
className="w-full h-auto rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentQuestion?.context &&
|
||||
currentQuestion.context !== "NULL" && (
|
||||
<div className="t-card p-6">
|
||||
<HighlightableRichText
|
||||
fieldKey={`${currentQuestion?.id ?? "unknown"}:context`}
|
||||
|
||||
@ -156,29 +156,6 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
// async startPracticeSession(
|
||||
// token: string,
|
||||
// sessionData: PracticeSessionRequest,
|
||||
// ): Promise<SessionResponse> {
|
||||
// return this.authenticatedRequest<SessionResponse>(`/sessions/`, token, {
|
||||
// method: "POST",
|
||||
// body: JSON.stringify(sessionData),
|
||||
// });
|
||||
// }
|
||||
// async startTargetedPracticeSession(
|
||||
// token: string,
|
||||
// sessionData: TargetedSessionRequest,
|
||||
// ): Promise<TargetedSessionResponse> {
|
||||
// return this.authenticatedRequest<TargetedSessionResponse>(
|
||||
// `/sessions/`,
|
||||
// token,
|
||||
// {
|
||||
// method: "POST",
|
||||
// body: JSON.stringify(sessionData),
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
async startSession(
|
||||
token: string,
|
||||
sessionData: SessionRequest,
|
||||
|
||||
Reference in New Issue
Block a user