feat(quests): improve 3d island styling
fix(test): fix context image not appearing on test screen
This commit is contained in:
@ -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); // 7–11 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) => {
|
||||||
|
|||||||
@ -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>;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user