|
| 1 | +import React, { useState, useEffect, useCallback, Suspense } from 'react'; |
| 2 | +import { Canvas } from '@react-three/fiber'; |
| 3 | +import { AsciiRenderer, Float, useTexture, OrbitControls, useGLTF } from '@react-three/drei'; |
| 4 | +import * as THREE from 'three'; |
| 5 | +import { useTheme } from 'next-themes'; |
| 6 | + |
| 7 | +// ---------------------------------------------------- |
| 8 | +// SCENE COMPONENTS |
| 9 | +// ---------------------------------------------------- |
| 10 | + |
| 11 | +function FlowCoin() { |
| 12 | + const texture = useTexture('/assets/flow-logo.svg'); |
| 13 | + |
| 14 | + return ( |
| 15 | + <group scale={0.85}> |
| 16 | + {/* Front Face */} |
| 17 | + <mesh position={[0, 0, 0.11]}> |
| 18 | + <circleGeometry args={[2, 64]} /> |
| 19 | + <meshStandardMaterial map={texture} roughness={0.3} metalness={0.8} /> |
| 20 | + </mesh> |
| 21 | + {/* Back Face */} |
| 22 | + <mesh position={[0, 0, -0.11]} rotation={[0, Math.PI, 0]}> |
| 23 | + <circleGeometry args={[2, 64]} /> |
| 24 | + <meshStandardMaterial map={texture} roughness={0.3} metalness={0.8} /> |
| 25 | + </mesh> |
| 26 | + {/* Edge */} |
| 27 | + <mesh rotation={[Math.PI / 2, 0, 0]}> |
| 28 | + <cylinderGeometry args={[2, 2, 0.22, 64, 1, true]} /> |
| 29 | + <meshStandardMaterial color="#00FF94" roughness={0.3} metalness={0.8} /> |
| 30 | + </mesh> |
| 31 | + </group> |
| 32 | + ); |
| 33 | +} |
| 34 | + |
| 35 | +function CadenceLogo3D() { |
| 36 | + const { scene } = useGLTF('/assets/logo.glb'); |
| 37 | + |
| 38 | + useEffect(() => { |
| 39 | + scene.traverse((child) => { |
| 40 | + if (child instanceof THREE.Mesh) { |
| 41 | + child.material.roughness = 0.4; |
| 42 | + child.material.metalness = 0.6; |
| 43 | + } |
| 44 | + }); |
| 45 | + }, [scene]); |
| 46 | + |
| 47 | + return ( |
| 48 | + <group position={[0, 0, 0]}> |
| 49 | + <primitive object={scene} scale={1.2} /> |
| 50 | + </group> |
| 51 | + ); |
| 52 | +} |
| 53 | + |
| 54 | +function CryptoKitty3D() { |
| 55 | + const { scene } = useGLTF('/assets/cryptokitty.glb'); |
| 56 | + |
| 57 | + useEffect(() => { |
| 58 | + scene.traverse((child) => { |
| 59 | + if (child instanceof THREE.Mesh) { |
| 60 | + child.material.roughness = 0.5; |
| 61 | + child.material.metalness = 0.2; |
| 62 | + } |
| 63 | + }); |
| 64 | + }, [scene]); |
| 65 | + |
| 66 | + return ( |
| 67 | + <group position={[0, -0.4, 0]}> |
| 68 | + <primitive object={scene} scale={2.2} /> |
| 69 | + </group> |
| 70 | + ); |
| 71 | +} |
| 72 | + |
| 73 | +useGLTF.preload('/assets/logo.glb'); |
| 74 | +useGLTF.preload('/assets/cryptokitty.glb'); |
| 75 | + |
| 76 | +function AsciiScene({ activeCycleIdx, fgColor }: { activeCycleIdx: number; fgColor: string }) { |
| 77 | + return ( |
| 78 | + <> |
| 79 | + <ambientLight intensity={1.5} /> |
| 80 | + <directionalLight position={[10, 10, 10]} intensity={3} /> |
| 81 | + <pointLight position={[-10, -10, -10]} intensity={1} /> |
| 82 | + |
| 83 | + <Suspense fallback={null}> |
| 84 | + <Float speed={2.5} rotationIntensity={0.2} floatIntensity={0.5}> |
| 85 | + <group visible={activeCycleIdx === 0}> |
| 86 | + <CadenceLogo3D /> |
| 87 | + </group> |
| 88 | + <group visible={activeCycleIdx === 1}> |
| 89 | + <FlowCoin /> |
| 90 | + </group> |
| 91 | + <group visible={activeCycleIdx === 2}> |
| 92 | + <CryptoKitty3D /> |
| 93 | + </group> |
| 94 | + </Float> |
| 95 | + </Suspense> |
| 96 | + |
| 97 | + {/* Ascii shader layer */} |
| 98 | + <AsciiRenderer |
| 99 | + fgColor={fgColor} |
| 100 | + bgColor="transparent" |
| 101 | + characters=" .:'-+*=%@#" |
| 102 | + resolution={0.25} |
| 103 | + color={false} |
| 104 | + invert={false} |
| 105 | + /> |
| 106 | + </> |
| 107 | + ); |
| 108 | +} |
| 109 | + |
| 110 | +// ---------------------------------------------------- |
| 111 | +// MAIN COMPONENT |
| 112 | +// ---------------------------------------------------- |
| 113 | + |
| 114 | +export function MorphingAscii() { |
| 115 | + const [activeCycleIdx, setActiveCycleIdx] = useState(0); |
| 116 | + const [mounted, setMounted] = useState(false); |
| 117 | + const [ready, setReady] = useState(false); |
| 118 | + const [isMobile, setIsMobile] = useState(false); |
| 119 | + const { resolvedTheme } = useTheme(); |
| 120 | + |
| 121 | + const onCreated = useCallback(() => { |
| 122 | + // Wait two frames so AsciiRenderer has fully taken over |
| 123 | + requestAnimationFrame(() => { |
| 124 | + requestAnimationFrame(() => { |
| 125 | + setReady(true); |
| 126 | + }); |
| 127 | + }); |
| 128 | + }, []); |
| 129 | + |
| 130 | + useEffect(() => { |
| 131 | + setMounted(true); |
| 132 | + const mq = window.matchMedia('(max-width: 1023px)'); |
| 133 | + setIsMobile(mq.matches); |
| 134 | + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); |
| 135 | + mq.addEventListener('change', handler); |
| 136 | + return () => mq.removeEventListener('change', handler); |
| 137 | + }, []); |
| 138 | + |
| 139 | + useEffect(() => { |
| 140 | + const interval = setInterval(() => { |
| 141 | + setActiveCycleIdx(prev => (prev + 1) % 3); |
| 142 | + }, 6000); |
| 143 | + return () => clearInterval(interval); |
| 144 | + }, []); |
| 145 | + |
| 146 | + if (!mounted) { |
| 147 | + return <div className="w-full h-[280px] lg:h-[700px] lg:w-[700px]" />; |
| 148 | + } |
| 149 | + |
| 150 | + return ( |
| 151 | + <div |
| 152 | + className={[ |
| 153 | + 'relative flex flex-col items-center justify-center p-0 m-0 group', |
| 154 | + 'w-full h-[280px]', |
| 155 | + 'lg:h-[700px] lg:w-[700px] lg:-right-5', |
| 156 | + 'overflow-hidden lg:overflow-visible', |
| 157 | + isMobile ? '' : 'cursor-grab active:cursor-grabbing', |
| 158 | + ].join(' ')} |
| 159 | + > |
| 160 | + {/* 3D ASCII Canvas */} |
| 161 | + <div |
| 162 | + className="absolute inset-0 overflow-hidden lg:overflow-visible ascii-wrapper" |
| 163 | + style={{ |
| 164 | + opacity: ready ? 1 : 0, |
| 165 | + transition: 'opacity 0.3s ease-in', |
| 166 | + }} |
| 167 | + > |
| 168 | + {resolvedTheme === 'light' && ( |
| 169 | + <style>{` |
| 170 | + .ascii-wrapper > div { |
| 171 | + font-weight: 900 !important; |
| 172 | + } |
| 173 | + `}</style> |
| 174 | + )} |
| 175 | + <Canvas |
| 176 | + camera={{ position: [0, 0, 6.5], fov: 50 }} |
| 177 | + style={{ touchAction: isMobile ? 'pan-y' : 'none' }} |
| 178 | + onCreated={onCreated} |
| 179 | + > |
| 180 | + <color attach="background" args={['transparent']} /> |
| 181 | + <AsciiScene |
| 182 | + activeCycleIdx={activeCycleIdx} |
| 183 | + fgColor={resolvedTheme === 'light' ? '#000000' : '#00FF94'} |
| 184 | + /> |
| 185 | + <OrbitControls |
| 186 | + autoRotate |
| 187 | + autoRotateSpeed={6} |
| 188 | + enableZoom={false} |
| 189 | + enablePan={false} |
| 190 | + enableRotate={!isMobile} |
| 191 | + /> |
| 192 | + </Canvas> |
| 193 | + </div> |
| 194 | + |
| 195 | + {/* Mobile: transparent overlay above ascii-wrapper to intercept touches |
| 196 | + and pass vertical scroll to the browser natively via pan-y */} |
| 197 | + {isMobile && ( |
| 198 | + <div |
| 199 | + style={{ |
| 200 | + position: 'absolute', |
| 201 | + inset: 0, |
| 202 | + zIndex: 10, |
| 203 | + touchAction: 'pan-y', |
| 204 | + }} |
| 205 | + /> |
| 206 | + )} |
| 207 | + </div> |
| 208 | + ); |
| 209 | +} |
0 commit comments