diff --git a/src/components/Island3D.tsx b/src/components/Island3D.tsx index dd45e66..fe87f54 100644 --- a/src/components/Island3D.tsx +++ b/src/components/Island3D.tsx @@ -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 = { 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 ( + + + + + {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 ( + + + + ); + })} + {rng() > 0.4 && ( + + + + + )} + + ); +}; + +// ─── 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 ( + + + + + + {Array.from({ length: layers }, (_, i) => ( + + + + + ))} + + ); +}; + +// ─── 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 ( + + {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 ( + + + + + ); + })} + + ); +}; + +// ─── 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 ( + + + + + {hasSnow && ( + + + + )} + {/* Lava glow for volcanic */} + {!hasSnow && rng() > 0.55 && ( + + + + + )} + + ); +}; + +// ─── 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 ( + + + + ); +}; + +// ─── 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(null!); + + useFrame(({ clock }) => { + if (meshRef.current) { + ( + meshRef.current.material as THREE.MeshStandardMaterial + ).emissiveIntensity = 0.18 + Math.sin(clock.elapsedTime * 1.1) * 0.06; + } + }); + return ( + + + + + ); +}; + +// ─── 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 ( + + {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 ( + + + + + + + + + + + ); + })} + + ); +}; + +// ─── 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 ( + + + + + + + + + + + + + + + ); +}; + +// ─── 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 ( + + {hasBeach && } + {hasLagoon && } + + {hasMountain && ( + + )} + + {Array.from({ length: palmCount }, (_, i) => ( + + ))} + {Array.from({ length: pineCount }, (_, i) => ( + + ))} + {Array.from({ length: rockCount }, (_, i) => ( + + ))} + {Array.from({ length: flowerCount }, (_, i) => ( + + ))} + {hasHut && ( + + )} + + ); +}; + +// ─── Current island marker ──────────────────────────────────────────────────── +const CurrentMarker = ({ accent }: { accent: string }) => { + const groupRef = useRef(null!); + const innerRef = useRef(null!); + const ringRef = useRef(null!); + const beamRef = useRef(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 ( + + + + + + + + + + + + {[0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2].map((angle, i) => ( + + + + ))} + {[ + { 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) => ( + + + + + ))} + + + + + + + YOU ARE HERE + + + + ); +}; + +// ─── Island3D ───────────────────────────────────────────────────────────────── export const Island3D = ({ node, position, @@ -103,51 +679,44 @@ export const Island3D = ({ onTap, onClaim, index, + isCurrent = false, modalOpen = false, }: Island3DProps) => { const topMeshRef = useRef(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"; + const seed = index * 13 + 7; - // ── Geometry — ExtrudeGeometry gives correct normals, solid faces ────────── const { topGeo, cliffGeo } = useMemo(() => { - const seed = index * 13 + 7; - - // Top plateau shape (slightly smaller to show cliff peeking out below) const topShape = makeIslandShape(seed, 1.0); const topGeo = new THREE.ExtrudeGeometry(topShape, { - depth: 0.3, + 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 */} - {/* ── Cliff body — solid, darker shade ──────────────────────────── */} + {/* Cliff */} - {/* ── Water-level glow ring ──────────────────────────────────────── */} + {/* Procedural terrain */} + + + {/* Water-level glow ring */} {!isLocked && ( @@ -244,7 +813,7 @@ export const Island3D = ({ )} - {/* ── Claimable sparkles + top ring ─────────────────────────────── */} + {/* Claimable sparkles */} {isClaimable && ( <> )} - {/* ── Hover ring ────────────────────────────────────────────────── */} + {/* Hover ring */} {hovered && !isLocked && ( @@ -285,7 +854,7 @@ export const Island3D = ({ )} - {/* ── Billboard emoji above island ───────────────────────────────── */} + {/* Emoji */} - {/* ── Info card ─────────────────────────────────────────────────── */} + {/* Current marker */} + {isCurrent && } + + {/* Info card */} - {/* Name */}
{node.name ?? "—"}
- {/* XP + status */}
{isClaimable @@ -421,7 +991,6 @@ export const Island3D = ({
- {/* Progress bar */} {isActive && node.req_target > 0 && (
)} - {/* Claim button */} {isClaimable && ( + + + {/* Deep-sea atmospheric background */} +
+
+
+ + {/* Floating wreckage debris */} + {[...Array(8)].map((_, i) => ( +
+ ))} + +
+ {/* ── Shipwreck SVG illustration ── */} +
+ + {/* Ocean surface rings behind the wreck */} + + + + + + + + + {/* Broken mast — leaning left */} + + + + {/* Shredded sail */} + + + {/* Torn pirate flag */} + + + + + + + {/* Hull — cracked, half-submerged */} + + + {/* Crack lines */} + + + {/* Porthole */} + + + {/* Water-line sheen */} + + + + {/* Barnacles */} + + + + + {/* Floating planks */} + + + + + + + + {/* Half-submerged treasure chest */} + + + + + + + + + + {/* Rising bubbles */} + + + + + + + + + + + + + +
+ + {/* Copy & actions */} +
+
⚓ SHIPWRECKED
+

Lost at Sea

+

+ {fetchError + ? "The charts couldn't be retrieved from the depths." + : "Your quest map has sunk without a trace."} +

+
+ + + {fetchError ?? "No quest data found"} + +
+ +

+ Your progress and treasures are safely stored in Davy Jones' + vault. +

+
); diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx index ce281ed..86caa46 100644 --- a/src/pages/student/practice/Test.tsx +++ b/src/pages/student/practice/Test.tsx @@ -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 }) => ( + + {latex} + + ); + + const SafeInlineMath = ({ latex }: { latex: string }) => ( + + {latex} + + ); + 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 = {inner}; + const node = ; 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 = {inner}; + const node = ; pos += len; if (!hasOverlap) return node; return ( @@ -1684,15 +1700,16 @@ export const Test = () => {

)} - {currentQuestion?.context_image_url !== "NULL" && ( -
- Context -
- )} + {currentQuestion?.context_image_url && + currentQuestion.context_image_url !== "NULL" && ( +
+ Question context +
+ )}

{currentQuestion?.text && @@ -1735,15 +1752,26 @@ export const Test = () => {

) : ( <> - {currentQuestion?.context && ( -
- -
- )} + {currentQuestion?.context_image_url && + currentQuestion.context_image_url !== "NULL" && ( +
+ Question context +
+ )} + {currentQuestion?.context && + currentQuestion.context !== "NULL" && ( +
+ +
+ )} )}
diff --git a/src/utils/api.ts b/src/utils/api.ts index 5604734..958acb0 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -156,29 +156,6 @@ class ApiClient { ); } - // async startPracticeSession( - // token: string, - // sessionData: PracticeSessionRequest, - // ): Promise { - // return this.authenticatedRequest(`/sessions/`, token, { - // method: "POST", - // body: JSON.stringify(sessionData), - // }); - // } - // async startTargetedPracticeSession( - // token: string, - // sessionData: TargetedSessionRequest, - // ): Promise { - // return this.authenticatedRequest( - // `/sessions/`, - // token, - // { - // method: "POST", - // body: JSON.stringify(sessionData), - // }, - // ); - // } - async startSession( token: string, sessionData: SessionRequest,