web #1

Merged
shafin808s merged 35 commits from web into main 2026-03-11 20:41:06 +00:00
212 changed files with 109185 additions and 2809 deletions
Showing only changes of commit 121cc2bf71 - Show all commits

View File

@ -13,11 +13,11 @@ export interface Island3DProps {
onTap: (node: QuestNode) => void; onTap: (node: QuestNode) => void;
onClaim: (node: QuestNode) => void; onClaim: (node: QuestNode) => void;
index: number; index: number;
/** When true the info card drops behind the modal overlay */ isCurrent?: boolean;
modalOpen?: boolean; modalOpen?: boolean;
} }
// ─── Seeded RNG (xorshift32) ────────────────────────────────────────────────── // ─── Seeded RNG ───────────────────────────────────────────────────────────────
const makeRng = (seed: number) => { const makeRng = (seed: number) => {
let s = ((seed + 1) * 1664525 + 1013904223) >>> 0; let s = ((seed + 1) * 1664525 + 1013904223) >>> 0;
return () => { return () => {
@ -28,23 +28,19 @@ const makeRng = (seed: number) => {
}; };
}; };
// ─── Irregular island Shape for ExtrudeGeometry ─────────────────────────────── // ─── Island shape ─────────────────────────────────────────────────────────────
// ExtrudeGeometry computes normals correctly — no manual BufferGeometry needed.
const makeIslandShape = (seed: number, radiusBase = 1.0): THREE.Shape => { const makeIslandShape = (seed: number, radiusBase = 1.0): THREE.Shape => {
const rng = makeRng(seed); const rng = makeRng(seed);
const sides = 7 + Math.floor(rng() * 5); // 711 sides const sides = 8 + Math.floor(rng() * 5);
const pts: THREE.Vector2[] = []; const pts: THREE.Vector2[] = [];
for (let i = 0; i < sides; i++) { for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2 - Math.PI / 2; 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( pts.push(
new THREE.Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius), new THREE.Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius),
); );
} }
return new THREE.Shape(pts);
const shape = new THREE.Shape(pts);
return shape;
}; };
// ─── Status palette ─────────────────────────────────────────────────────────── // ─── Status palette ───────────────────────────────────────────────────────────
@ -52,15 +48,11 @@ const PALETTE = {
LOCKED: { top: "#778fa0", side: "#526070", emissive: "#000000", ei: 0 }, LOCKED: { top: "#778fa0", side: "#526070", emissive: "#000000", ei: 0 },
ACTIVE: { top: "", side: "", emissive: "#ffffff", ei: 0 }, ACTIVE: { top: "", side: "", emissive: "#ffffff", ei: 0 },
CLAIMABLE: { top: "#f59e0b", side: "#d97706", emissive: "#fde68a", ei: 0.3 }, 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 ─────────────────────────────────────────────────────────────── // ─── 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; const CARD_SCALE = 0.8;
// Derived sizes — do not edit these directly
const C = { const C = {
width: 175 * CARD_SCALE, width: 175 * CARD_SCALE,
borderRadius: 13 * CARD_SCALE, borderRadius: 13 * CARD_SCALE,
@ -81,7 +73,6 @@ const C = {
btnPadV: 6 * CARD_SCALE, btnPadV: 6 * CARD_SCALE,
btnSize: 11 * CARD_SCALE, btnSize: 11 * CARD_SCALE,
btnRadius: 9 * CARD_SCALE, btnRadius: 9 * CARD_SCALE,
// Html position: how far the card sits from the island centre
posX: 2.1 * CARD_SCALE, posX: 2.1 * CARD_SCALE,
}; };
@ -95,6 +86,591 @@ const REQ_EMOJI: Record<string, string> = {
leaderboard: "🏆", 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 = ({ export const Island3D = ({
node, node,
position, position,
@ -103,51 +679,44 @@ export const Island3D = ({
onTap, onTap,
onClaim, onClaim,
index, index,
isCurrent = false,
modalOpen = false, modalOpen = false,
}: Island3DProps) => { }: Island3DProps) => {
const topMeshRef = useRef<THREE.Mesh>(null!); const topMeshRef = useRef<THREE.Mesh>(null!);
const [hovered, setHovered] = useState(false); 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 isLocked = status === "LOCKED";
const isClaimable = status === "CLAIMABLE"; const isClaimable = status === "CLAIMABLE";
const isActive = status === "ACTIVE"; const isActive = status === "ACTIVE";
const isCompleted = status === "COMPLETED"; const isCompleted = status === "COMPLETED";
const seed = index * 13 + 7;
// ── Geometry — ExtrudeGeometry gives correct normals, solid faces ──────────
const { topGeo, cliffGeo } = useMemo(() => { 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 topShape = makeIslandShape(seed, 1.0);
const topGeo = new THREE.ExtrudeGeometry(topShape, { const topGeo = new THREE.ExtrudeGeometry(topShape, {
depth: 0.3, depth: 0.32,
bevelEnabled: true, bevelEnabled: true,
bevelThickness: 0.1, bevelThickness: 0.12,
bevelSize: 0.07, bevelSize: 0.08,
bevelSegments: 4, bevelSegments: 5,
}); });
// ExtrudeGeometry extrudes along Z — rotate to lie flat (XZ plane)
topGeo.rotateX(-Math.PI / 2); 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.14);
const cliffShape = makeIslandShape(seed, 1.12);
const cliffGeo = new THREE.ExtrudeGeometry(cliffShape, { const cliffGeo = new THREE.ExtrudeGeometry(cliffShape, {
depth: 0.55, depth: 0.58,
bevelEnabled: true, bevelEnabled: true,
bevelThickness: 0.05, bevelThickness: 0.06,
bevelSize: 0.04, bevelSize: 0.04,
bevelSegments: 2, bevelSegments: 2,
}); });
cliffGeo.rotateX(-Math.PI / 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 }; return { topGeo, cliffGeo };
}, [index]); }, [seed]);
// ── Colours ───────────────────────────────────────────────────────────────
const pal = PALETTE[status]; const pal = PALETTE[status];
const topColor = isActive const topColor = isActive
? terrain.l ? terrain.l
@ -160,16 +729,15 @@ export const Island3D = ({
? PALETTE.COMPLETED.side ? PALETTE.COMPLETED.side
: pal.side; : pal.side;
// ── Hover scale spring ────────────────────────────────────────────────────
useFrame((_, dt) => { useFrame((_, dt) => {
if (!topMeshRef.current) return; if (!topMeshRef.current) return;
const target = hovered && !isLocked ? 1.07 : 1.0; const target = hovered && !isLocked ? 1.07 : 1.0;
const curr = topMeshRef.current.scale.x; const curr = topMeshRef.current.scale.x;
const next = curr + (target - curr) * (1 - Math.exp(-14 * dt)); topMeshRef.current.scale.setScalar(
topMeshRef.current.scale.setScalar(next); curr + (target - curr) * (1 - Math.exp(-14 * dt)),
);
}); });
// ── Emoji ────────────────────────────────────────────────────────────────
const emoji = isLocked const emoji = isLocked
? "🔒" ? "🔒"
: isClaimable : isClaimable
@ -177,12 +745,10 @@ export const Island3D = ({
: isCompleted : isCompleted
? "✅" ? "✅"
: (REQ_EMOJI[node.req_type] ?? "🏝️"); : (REQ_EMOJI[node.req_type] ?? "🏝️");
const pct = const pct =
node.req_target > 0 node.req_target > 0
? Math.min(100, Math.round((node.current_value / node.req_target) * 100)) ? Math.min(100, Math.round((node.current_value / node.req_target) * 100))
: 0; : 0;
const ringColor = isClaimable ? "#fbbf24" : isCompleted ? "#4ade80" : accent; const ringColor = isClaimable ? "#fbbf24" : isCompleted ? "#4ade80" : accent;
return ( return (
@ -206,29 +772,32 @@ export const Island3D = ({
if (!isLocked) onTap(node); if (!isLocked) onTap(node);
}} }}
> >
{/* ── Top plateau — solid, opaque ────────────────────────────────── */} {/* Top plateau */}
<mesh ref={topMeshRef} geometry={topGeo} castShadow receiveShadow> <mesh ref={topMeshRef} geometry={topGeo} castShadow receiveShadow>
<meshStandardMaterial <meshStandardMaterial
color={topColor} color={topColor}
emissive={pal.emissive} emissive={pal.emissive}
emissiveIntensity={hovered ? pal.ei * 2.2 : pal.ei} emissiveIntensity={hovered ? pal.ei * 2.2 : pal.ei}
roughness={0.55} roughness={0.6}
metalness={0.06} metalness={0.04}
/> />
</mesh> </mesh>
{/* ── Cliff body — solid, darker shade ──────────────────────────── */} {/* Cliff */}
<mesh geometry={cliffGeo} castShadow> <mesh geometry={cliffGeo} castShadow>
<meshStandardMaterial <meshStandardMaterial
color={sideColor} color={sideColor}
roughness={0.82} roughness={0.88}
metalness={0.02} metalness={0.02}
emissive={pal.emissive} emissive={pal.emissive}
emissiveIntensity={pal.ei * 0.25} emissiveIntensity={pal.ei * 0.25}
/> />
</mesh> </mesh>
{/* ── Water-level glow ring ──────────────────────────────────────── */} {/* Procedural terrain */}
<IslandTerrain seed={seed} isLocked={isLocked} />
{/* Water-level glow ring */}
{!isLocked && ( {!isLocked && (
<mesh rotation-x={-Math.PI / 2} position-y={-0.86}> <mesh rotation-x={-Math.PI / 2} position-y={-0.86}>
<ringGeometry args={[1.3, 1.7, 48]} /> <ringGeometry args={[1.3, 1.7, 48]} />
@ -244,7 +813,7 @@ export const Island3D = ({
</mesh> </mesh>
)} )}
{/* ── Claimable sparkles + top ring ─────────────────────────────── */} {/* Claimable sparkles */}
{isClaimable && ( {isClaimable && (
<> <>
<Sparkles <Sparkles
@ -269,7 +838,7 @@ export const Island3D = ({
</> </>
)} )}
{/* ── Hover ring ────────────────────────────────────────────────── */} {/* Hover ring */}
{hovered && !isLocked && ( {hovered && !isLocked && (
<mesh rotation-x={-Math.PI / 2} position-y={0.06}> <mesh rotation-x={-Math.PI / 2} position-y={0.06}>
<ringGeometry args={[1.0, 1.65, 40]} /> <ringGeometry args={[1.0, 1.65, 40]} />
@ -285,7 +854,7 @@ export const Island3D = ({
</mesh> </mesh>
)} )}
{/* ── Billboard emoji above island ───────────────────────────────── */} {/* Emoji */}
<Billboard> <Billboard>
<Text <Text
position={[0, 1.3, 0]} position={[0, 1.3, 0]}
@ -297,7 +866,10 @@ export const Island3D = ({
</Text> </Text>
</Billboard> </Billboard>
{/* ── Info card ─────────────────────────────────────────────────── */} {/* Current marker */}
{isCurrent && <CurrentMarker accent={accent} />}
{/* Info card */}
<Html <Html
position={[C.posX, 0.1, 0]} position={[C.posX, 0.1, 0]}
transform={false} transform={false}
@ -352,27 +924,25 @@ export const Island3D = ({
fontFamily: "'Nunito', 'Nunito Sans', sans-serif", fontFamily: "'Nunito', 'Nunito Sans', sans-serif",
}} }}
> >
{/* Name */}
<div <div
style={{ style={{
fontWeight: 800, fontWeight: 800,
fontSize: C.nameSize, fontSize: C.nameSize,
color: isClaimable
? "#fde68a"
: isLocked
? "rgba(255,255,255,0.28)"
: "rgba(255,255,255,0.93)",
lineHeight: 1.3, lineHeight: 1.3,
marginBottom: C.nameMb, marginBottom: C.nameMb,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
color: isClaimable
? "#fde68a"
: isLocked
? "rgba(255,255,255,0.28)"
: "rgba(255,255,255,0.93)",
}} }}
> >
{node.name ?? "—"} {node.name ?? "—"}
</div> </div>
{/* XP + status */}
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -399,6 +969,9 @@ export const Island3D = ({
style={{ style={{
fontSize: C.statusSize, fontSize: C.statusSize,
fontWeight: 800, fontWeight: 800,
textTransform: "uppercase",
letterSpacing: "0.07em",
whiteSpace: "nowrap",
color: isClaimable color: isClaimable
? "#fbbf24" ? "#fbbf24"
: isCompleted : isCompleted
@ -406,9 +979,6 @@ export const Island3D = ({
: isActive : isActive
? "rgba(255,255,255,0.5)" ? "rgba(255,255,255,0.5)"
: "rgba(255,255,255,0.18)", : "rgba(255,255,255,0.18)",
textTransform: "uppercase",
letterSpacing: "0.07em",
whiteSpace: "nowrap",
}} }}
> >
{isClaimable {isClaimable
@ -421,7 +991,6 @@ export const Island3D = ({
</span> </span>
</div> </div>
{/* Progress bar */}
{isActive && node.req_target > 0 && ( {isActive && node.req_target > 0 && (
<div <div
style={{ style={{
@ -444,7 +1013,6 @@ export const Island3D = ({
</div> </div>
)} )}
{/* Claim button */}
{isClaimable && ( {isClaimable && (
<button <button
onClick={(e) => { onClick={(e) => {

View File

@ -1,16 +1,76 @@
import { Component, type ReactNode } from "react";
import { BlockMath, InlineMath } from "react-katex"; 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) => { export const renderQuestionText = (text: string) => {
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g); if (!text) return null;
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/gs);
return ( return (
<> <>
{parts.map((part, index) => { {parts.map((part, index) => {
if (part.startsWith("$$")) { 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("$")) { 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>; return <span key={index}>{part}</span>;
})} })}

View File

@ -20,14 +20,6 @@ const VW = 420;
const TOP_PAD = 80; const TOP_PAD = 80;
const ROW_H = 520; // vertical step per island (increased for more separation) 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 ─────────────────────────────────────────────────────────────── // ─── Seeded RNG ───────────────────────────────────────────────────────────────
const mkRng = (seed: number) => { const mkRng = (seed: number) => {
let s = seed >>> 0; let s = seed >>> 0;
@ -108,23 +100,6 @@ const generateIslandPositions = (
return positions; 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 ─────────────────────────────────────────────────────────────────── // ─── Styles ───────────────────────────────────────────────────────────────────
const 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'); @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; } .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 ──────────────────────────────────────────────────────────────── // ─── Arc theme ────────────────────────────────────────────────────────────────
export interface ArcTheme { export interface ArcTheme {
accent: string; accent: string;
@ -936,17 +1032,16 @@ const IslandScene = ({
modalOpen, modalOpen,
}: MapContentProps) => { }: MapContentProps) => {
const nodeCount = arc.nodes.length; const nodeCount = arc.nodes.length;
const tropicalTerrain = {
l: "#3ecf6a",
m: "#1fa84a",
d: "#157a36",
s: "#03045e",
};
const sorted = useMemo( const sorted = useMemo(
() => [...arc.nodes].sort((a, b) => a.sequence_order - b.sequence_order), () => [...arc.nodes].sort((a, b) => a.sequence_order - b.sequence_order),
[arc.nodes], [arc.nodes],
); );
const currentIdx = sorted.findIndex(
(n) => n.status === "ACTIVE" || n.status === "CLAIMABLE",
);
return ( return (
<> <>
<WaterPlane /> <WaterPlane />
@ -974,9 +1069,10 @@ const IslandScene = ({
index={i} index={i}
position={[x3, 0, z3]} position={[x3, 0, z3]}
accent={theme.accent} accent={theme.accent}
terrain={tropicalTerrain} terrain={theme.terrain}
onTap={onNodeTap} onTap={onNodeTap}
onClaim={onClaim} onClaim={onClaim}
isCurrent={i === currentIdx} // ← add this
modalOpen={modalOpen} modalOpen={modalOpen}
/> />
); );
@ -1583,45 +1679,356 @@ export const QuestMap = () => {
style={{ style={{
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: "2rem", overflow: "hidden",
}} }}
> >
<style>{STYLES}</style> <style>
<div {STYLES}
style={{ {ERROR_STYLES}
textAlign: "center", </style>
color: "rgba(255,255,255,0.5)",
fontFamily: "'Nunito',sans-serif", {/* Deep-sea atmospheric background */}
}} <div className="qme-bg" />
> <div className="qme-fog qme-fog1" />
<div style={{ fontSize: "2.5rem", marginBottom: "1rem" }}>🌊</div> <div className="qme-fog qme-fog2" />
<p
style={{ {/* Floating wreckage debris */}
fontSize: "0.85rem", {[...Array(8)].map((_, i) => (
fontWeight: 800, <div key={i} className={`qme-debris qme-debris-${i}`} />
color: "#ef4444", ))}
marginBottom: "0.5rem",
}} <div className="qme-stage">
> {/* ── Shipwreck SVG illustration ── */}
{fetchError ?? "No quest data found"} <div className="qme-ship-wrap">
</p> <svg
<button className="qme-ship-svg"
onClick={() => window.location.reload()} viewBox="0 0 240 180"
style={{ fill="none"
marginTop: "1rem", xmlns="http://www.w3.org/2000/svg"
padding: "0.5rem 1.25rem", >
borderRadius: "100px", {/* Ocean surface rings behind the wreck */}
border: "1px solid rgba(255,255,255,0.15)", <ellipse
background: "transparent", cx="120"
color: "rgba(255,255,255,0.5)", cy="145"
cursor: "pointer", rx="100"
fontFamily: "'Nunito',sans-serif", ry="18"
fontWeight: 800, fill="rgba(3,4,94,0.6)"
fontSize: "0.75rem", />
}} <path
> d="M20 145 Q45 132 70 145 Q95 158 120 145 Q145 132 170 145 Q195 158 220 145"
Try again stroke="#0077b6"
</button> strokeWidth="2"
fill="none"
opacity="0.5"
strokeDasharray="6 4"
>
<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()}
>
<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>
</div> </div>
); );

View File

@ -32,7 +32,10 @@ import type {
SubmitAnswer, SubmitAnswer,
} from "../../../types/session"; } from "../../../types/session";
import { useAuthToken } from "../../../hooks/useAuthToken"; import { useAuthToken } from "../../../hooks/useAuthToken";
import { renderQuestionText } from "../../../components/RenderQuestionText"; import {
MathErrorBoundary,
renderQuestionText,
} from "../../../components/RenderQuestionText";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, 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 = ( const renderQuestionTextWithHighlights = (
text: string, text: string,
highlights: HighlightRange[], highlights: HighlightRange[],
@ -1259,7 +1275,7 @@ export const Test = () => {
const hasOverlap = merged.some( const hasOverlap = merged.some(
(h) => h.end > pos && h.start < pos + len, (h) => h.end > pos && h.start < pos + len,
); );
const node = <BlockMath key={index}>{inner}</BlockMath>; const node = <SafeBlockMath key={index} latex={inner} />;
pos += len; pos += len;
if (!hasOverlap) return node; if (!hasOverlap) return node;
return ( return (
@ -1274,7 +1290,7 @@ export const Test = () => {
const hasOverlap = merged.some( const hasOverlap = merged.some(
(h) => h.end > pos && h.start < pos + len, (h) => h.end > pos && h.start < pos + len,
); );
const node = <InlineMath key={index}>{inner}</InlineMath>; const node = <SafeInlineMath key={index} latex={inner} />;
pos += len; pos += len;
if (!hasOverlap) return node; if (!hasOverlap) return node;
return ( return (
@ -1684,15 +1700,16 @@ export const Test = () => {
</p> </p>
</div> </div>
)} )}
{currentQuestion?.context_image_url !== "NULL" && ( {currentQuestion?.context_image_url &&
<div className="t-card p-6"> currentQuestion.context_image_url !== "NULL" && (
<img <div className="t-card p-6">
src={currentQuestion?.context_image_url} <img
alt="Context" src="https://placehold.co/600x400"
className="w-full h-auto" alt="Question context"
/> className="w-full h-auto rounded-xl"
</div> />
)} </div>
)}
<div className="t-card t-card-purple p-6"> <div className="t-card t-card-purple p-6">
<p className="font-bold text-lg text-[#1e1b4b] leading-relaxed"> <p className="font-bold text-lg text-[#1e1b4b] leading-relaxed">
{currentQuestion?.text && {currentQuestion?.text &&
@ -1735,15 +1752,26 @@ export const Test = () => {
</div> </div>
) : ( ) : (
<> <>
{currentQuestion?.context && ( {currentQuestion?.context_image_url &&
<div className="t-card p-6"> currentQuestion.context_image_url !== "NULL" && (
<HighlightableRichText <div className="t-card p-6">
fieldKey={`${currentQuestion?.id ?? "unknown"}:context`} <img
text={currentQuestion.context} src="https://placehold.co/600x400"
className="font-semibold text-gray-700 leading-relaxed" alt="Question context"
/> className="w-full h-auto rounded-xl"
</div> />
)} </div>
)}
{currentQuestion?.context &&
currentQuestion.context !== "NULL" && (
<div className="t-card p-6">
<HighlightableRichText
fieldKey={`${currentQuestion?.id ?? "unknown"}:context`}
text={currentQuestion.context}
className="font-semibold text-gray-700 leading-relaxed"
/>
</div>
)}
</> </>
)} )}
</div> </div>

View File

@ -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( async startSession(
token: string, token: string,
sessionData: SessionRequest, sessionData: SessionRequest,