├── .gitignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── README.md ├── index.html ├── package.json ├── public ├── favicon.ico └── images │ └── games │ ├── 0.jpeg │ ├── 1.jpeg │ ├── 10.jpeg │ ├── 11.jpeg │ ├── 12.jpeg │ ├── 13.jpeg │ ├── 14.jpeg │ ├── 15.jpeg │ ├── 16.jpeg │ ├── 17.jpeg │ ├── 18.jpeg │ ├── 19.jpeg │ ├── 2.jpeg │ ├── 20.jpeg │ ├── 21.jpeg │ ├── 22.jpeg │ ├── 23.jpeg │ ├── 24.jpeg │ ├── 25.jpeg │ ├── 26.jpeg │ ├── 27.jpeg │ ├── 28.jpeg │ ├── 29.jpeg │ ├── 3.jpeg │ ├── 30.jpeg │ ├── 31.jpeg │ ├── 32.jpeg │ ├── 33.jpeg │ ├── 34.jpeg │ ├── 35.jpeg │ ├── 36.jpeg │ ├── 37.jpeg │ ├── 38.jpeg │ ├── 39.jpeg │ ├── 4.jpeg │ ├── 40.jpeg │ ├── 41.jpeg │ ├── 42.jpeg │ ├── 43.jpeg │ ├── 44.jpeg │ ├── 45.jpeg │ ├── 46.jpeg │ ├── 47.jpeg │ ├── 48.jpeg │ ├── 49.jpeg │ ├── 5.jpeg │ ├── 50.jpeg │ ├── 51.jpeg │ ├── 52.jpeg │ ├── 53.jpeg │ ├── 54.jpeg │ ├── 6.jpeg │ ├── 7.jpeg │ ├── 8.jpeg │ └── 9.jpeg ├── src ├── App.jsx ├── Sphere.jsx ├── SphereItem.jsx ├── components │ ├── Controls │ │ ├── Controls.jsx │ │ ├── Joystick │ │ │ └── Joystick.jsx │ │ ├── Slider │ │ │ └── Slider.jsx │ │ └── Toggle │ │ │ └── Toggle.jsx │ └── GameCard │ │ ├── GameContent.jsx │ │ └── GameCover.jsx ├── constants.js ├── hooks │ ├── useControlsUpdate.jsx │ ├── useInitialPosition.jsx │ ├── useKeyboardControls.jsx │ ├── useMediaQuery.jsx │ └── useSelectedPosition.jsx ├── index.jsx ├── styles.css └── utils.js ├── tailwind.config.js ├── vite.config.js ├── yarn-error.log └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | .DS_Store 4 | dist 5 | dist-ssr 6 | *.local -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "useTabs": false, 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false 9 | } 10 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | // .storybook/main.js 2 | const path = require('path') 3 | const WindiCSS = require('vite-plugin-windicss').default 4 | 5 | module.exports = { 6 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 7 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 8 | core: { 9 | builder: 'storybook-builder-vite', 10 | }, 11 | async viteFinal(config, { configType }) { 12 | config.plugins = config.plugins ?? [] 13 | config.plugins.push( 14 | WindiCSS({ 15 | config: path.join(__dirname, '..', 'tailwind.config.js'), 16 | }) 17 | ) 18 | return config 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import 'virtual:windi.css' 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://user-images.githubusercontent.com/530644/146213792-dc02bbfa-1bbc-4873-85a0-fb43f15e17c3.png) 2 | 3 | 4 | ## Setup and run 5 | 6 | ```console 7 | yarn 8 | yarn dev 9 | ``` 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "r3f-vite", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview", 8 | "storybook": "start-storybook -p 6006", 9 | "build-storybook": "build-storybook" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.4.2", 13 | "@radix-ui/react-icons": "^1.0.3", 14 | "@radix-ui/react-slider": "^0.1.1", 15 | "@react-three/a11y": "^2.2.3", 16 | "@react-three/drei": "7.22.4", 17 | "@react-three/fiber": "7.0.19", 18 | "@use-gesture/react": "^10.1.6", 19 | "leva": "^0.9.15", 20 | "lorem-ipsum": "^2.0.4", 21 | "react": "17.0.2", 22 | "react-dom": "17.0.2", 23 | "react-spring": "^9.3.1", 24 | "three": "0.134.0", 25 | "three-stdlib": "^2.5.9", 26 | "vite-plugin-windicss": "^1.5.1", 27 | "windicss": "^3.2.1", 28 | "zustand": "^3.6.5" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.16.0", 32 | "@storybook/addon-actions": "^6.4.0-rc.6", 33 | "@storybook/addon-essentials": "^6.4.0-rc.6", 34 | "@storybook/addon-links": "^6.4.0-rc.6", 35 | "@storybook/react": "^6.4.0-rc.6", 36 | "@vitejs/plugin-react": "^1.0.2", 37 | "babel-loader": "^8.2.3", 38 | "r3f-perf": "^4.9.1", 39 | "storybook-builder-vite": "^0.1.9", 40 | "vite": "^2.6.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/favicon.ico -------------------------------------------------------------------------------- /public/images/games/0.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/0.jpeg -------------------------------------------------------------------------------- /public/images/games/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/1.jpeg -------------------------------------------------------------------------------- /public/images/games/10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/10.jpeg -------------------------------------------------------------------------------- /public/images/games/11.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/11.jpeg -------------------------------------------------------------------------------- /public/images/games/12.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/12.jpeg -------------------------------------------------------------------------------- /public/images/games/13.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/13.jpeg -------------------------------------------------------------------------------- /public/images/games/14.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/14.jpeg -------------------------------------------------------------------------------- /public/images/games/15.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/15.jpeg -------------------------------------------------------------------------------- /public/images/games/16.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/16.jpeg -------------------------------------------------------------------------------- /public/images/games/17.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/17.jpeg -------------------------------------------------------------------------------- /public/images/games/18.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/18.jpeg -------------------------------------------------------------------------------- /public/images/games/19.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/19.jpeg -------------------------------------------------------------------------------- /public/images/games/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/2.jpeg -------------------------------------------------------------------------------- /public/images/games/20.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/20.jpeg -------------------------------------------------------------------------------- /public/images/games/21.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/21.jpeg -------------------------------------------------------------------------------- /public/images/games/22.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/22.jpeg -------------------------------------------------------------------------------- /public/images/games/23.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/23.jpeg -------------------------------------------------------------------------------- /public/images/games/24.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/24.jpeg -------------------------------------------------------------------------------- /public/images/games/25.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/25.jpeg -------------------------------------------------------------------------------- /public/images/games/26.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/26.jpeg -------------------------------------------------------------------------------- /public/images/games/27.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/27.jpeg -------------------------------------------------------------------------------- /public/images/games/28.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/28.jpeg -------------------------------------------------------------------------------- /public/images/games/29.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/29.jpeg -------------------------------------------------------------------------------- /public/images/games/3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/3.jpeg -------------------------------------------------------------------------------- /public/images/games/30.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/30.jpeg -------------------------------------------------------------------------------- /public/images/games/31.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/31.jpeg -------------------------------------------------------------------------------- /public/images/games/32.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/32.jpeg -------------------------------------------------------------------------------- /public/images/games/33.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/33.jpeg -------------------------------------------------------------------------------- /public/images/games/34.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/34.jpeg -------------------------------------------------------------------------------- /public/images/games/35.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/35.jpeg -------------------------------------------------------------------------------- /public/images/games/36.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/36.jpeg -------------------------------------------------------------------------------- /public/images/games/37.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/37.jpeg -------------------------------------------------------------------------------- /public/images/games/38.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/38.jpeg -------------------------------------------------------------------------------- /public/images/games/39.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/39.jpeg -------------------------------------------------------------------------------- /public/images/games/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/4.jpeg -------------------------------------------------------------------------------- /public/images/games/40.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/40.jpeg -------------------------------------------------------------------------------- /public/images/games/41.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/41.jpeg -------------------------------------------------------------------------------- /public/images/games/42.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/42.jpeg -------------------------------------------------------------------------------- /public/images/games/43.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/43.jpeg -------------------------------------------------------------------------------- /public/images/games/44.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/44.jpeg -------------------------------------------------------------------------------- /public/images/games/45.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/45.jpeg -------------------------------------------------------------------------------- /public/images/games/46.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/46.jpeg -------------------------------------------------------------------------------- /public/images/games/47.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/47.jpeg -------------------------------------------------------------------------------- /public/images/games/48.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/48.jpeg -------------------------------------------------------------------------------- /public/images/games/49.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/49.jpeg -------------------------------------------------------------------------------- /public/images/games/5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/5.jpeg -------------------------------------------------------------------------------- /public/images/games/50.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/50.jpeg -------------------------------------------------------------------------------- /public/images/games/51.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/51.jpeg -------------------------------------------------------------------------------- /public/images/games/52.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/52.jpeg -------------------------------------------------------------------------------- /public/images/games/53.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/53.jpeg -------------------------------------------------------------------------------- /public/images/games/54.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/54.jpeg -------------------------------------------------------------------------------- /public/images/games/6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/6.jpeg -------------------------------------------------------------------------------- /public/images/games/7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/7.jpeg -------------------------------------------------------------------------------- /public/images/games/8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/8.jpeg -------------------------------------------------------------------------------- /public/images/games/9.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/9.jpeg -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Stars } from '@react-three/drei' 2 | import { Canvas } from '@react-three/fiber' 3 | import { useEffect, useMemo, useRef, useState } from 'react' 4 | import * as THREE from 'three' 5 | import 'virtual:windi-devtools' 6 | import 'virtual:windi.css' 7 | import create from 'zustand' 8 | import Controls from './components/Controls/Controls' 9 | import { DEFAULT_CARD } from './constants' 10 | import useControlsUpdate from './hooks/useControlsUpdate' 11 | import useKeyboardControls from './hooks/useKeyboardControls' 12 | import Sphere from './Sphere' 13 | import './styles.css' 14 | import { getData, getNewIndex } from './utils' 15 | 16 | export const useStore = create((set) => ({ 17 | sphereControls: { left: false, right: false }, 18 | selectedIndex: DEFAULT_CARD, 19 | cardHidden: false, 20 | selectedVector: new THREE.Vector3(), 21 | ratingFilter: [0], 22 | setRight: () => set({ sphereControls: { left: false, right: true } }), 23 | setLeft: () => set({ sphereControls: { left: true, right: false } }), 24 | setControls: (sphereControls) => set({ sphereControls }), 25 | resetControls: () => set({ sphereControls: { left: false, right: false } }), 26 | setCardHidden: (cardHidden) => set({ cardHidden }), 27 | setSelectedIndex: (selectedIndex) => set({ selectedIndex }), 28 | setRatingFilter: (ratingFilter) => set({ ratingFilter }), 29 | })) 30 | 31 | export default function App() { 32 | const canvasContainerRef = useRef() 33 | const ratingFilter = useStore((state) => state.ratingFilter) 34 | const cardVisibleRef = useRef(false) 35 | const [data] = useState(getData()) 36 | const dataWithFiltered = useMemo(() => data.map((game) => ({ ...game, filtered: ratingFilter > game.pressRating })), [ratingFilter]) 37 | 38 | useControlsUpdate({ data: dataWithFiltered }) 39 | const { handleKeyDown, handleKeyUp } = useKeyboardControls() 40 | 41 | useEffect(() => { 42 | canvasContainerRef.current.focus() 43 | }, []) 44 | 45 | return ( 46 | <> 47 |
55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/Sphere.jsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from '@react-three/fiber' 2 | import React, { useEffect, useRef } from 'react' 3 | import * as THREE from 'three' 4 | import { useStore } from './App' 5 | import SphereItem from './SphereItem' 6 | 7 | const originVector = new THREE.Vector3(0, 0, 0) 8 | const Sphere = ({ cardVisibleRef, data }) => { 9 | const sphereRef = useRef() 10 | const selectedIndex = useStore((state) => state.selectedIndex) 11 | const selectedVector = useStore((state) => state.selectedVector) 12 | const setSelectedIndex = useStore((state) => state.setSelectedIndex) 13 | const ratingFilter = useStore((state) => state.ratingFilter) 14 | 15 | useFrame(({ camera }) => { 16 | camera.position.lerp(selectedVector, 0.02) 17 | camera.lookAt(originVector) 18 | if (!cardVisibleRef.current && camera.position.distanceTo(selectedVector) < 1) { 19 | cardVisibleRef.current = true 20 | } 21 | }) 22 | 23 | useEffect(() => { 24 | cardVisibleRef.current = false 25 | }, [selectedIndex]) 26 | 27 | useEffect(() => { 28 | setSelectedIndex(null) 29 | }, [ratingFilter]) 30 | 31 | return ( 32 | 33 | {data.map(({ id, playerRating, pressRating, filtered }, index) => ( 34 | 43 | ))} 44 | 45 | ) 46 | } 47 | 48 | export default Sphere 49 | -------------------------------------------------------------------------------- /src/SphereItem.jsx: -------------------------------------------------------------------------------- 1 | import { animated as a, useSpring } from '@react-spring/three' 2 | import { A11y } from '@react-three/a11y' 3 | import { Html, Plane } from '@react-three/drei' 4 | import { useFrame } from '@react-three/fiber' 5 | import React, { Suspense, useRef, useState } from 'react' 6 | import * as THREE from 'three' 7 | import { useStore } from './App' 8 | import GameContent from './components/GameCard/GameContent' 9 | import GameCard from './components/GameCard/GameCover' 10 | import useInitialPosition from './hooks/useInitialPosition' 11 | import useSelectedPosition from './hooks/useSelectedPosition' 12 | import useMediaQuery from './hooks/useMediaQuery' 13 | 14 | const cameraVector = new THREE.Vector3() 15 | const tempVector = new THREE.Vector3(0, 0, 0) 16 | const translationVector = new THREE.Vector3(-1, 0, 0) 17 | 18 | const bgMaterialProps = { 19 | thickness: 5, 20 | roughness: 0.8, 21 | clearcoat: 1, 22 | clearcoatRoughness: 0, 23 | transmission: 1, 24 | ior: 1.45, 25 | envMapIntensity: 25, 26 | color: '#ffffff', 27 | attenuationTint: '#ffe79e', 28 | attenuationDistance: 0, 29 | } 30 | 31 | const SphereItem = ({ cardIndex, isSelected, id, playerRating, pressRating, filtered }) => { 32 | const ref = useRef() 33 | const planeRef = useRef() 34 | const selectedVector = useStore((state) => state.selectedVector) 35 | const setSelectedIndex = useStore((state) => state.setSelectedIndex) 36 | const [initialPositionVector] = useState(new THREE.Vector3()) 37 | const [zoomPositionVector] = useState(new THREE.Vector3()) 38 | const isZoomCompleteRef = useRef(false) 39 | const [{ scale }, scaleApi] = useSpring(() => ({ 40 | scale: 0, 41 | })) 42 | const { scaleGroup } = useSpring({ 43 | scaleGroup: filtered ? 0 : 1, 44 | }) 45 | const isDesktop = useMediaQuery('(min-width: 960px)') 46 | 47 | useInitialPosition({ cameraVector, cardIndex, initialPositionVector, itemRef: ref }) 48 | useSelectedPosition({ isSelected, selectedVector, tempVector, zoomPositionVector, translationVector, itemRef: ref }) 49 | 50 | useFrame(() => { 51 | if (isSelected && !useStore.getState().cardHidden) { 52 | ref.current.position.lerp(zoomPositionVector, 0.1) 53 | const distance = ref.current.position.distanceTo(zoomPositionVector) 54 | if (!isZoomCompleteRef.current && distance !== 0 && distance < 0.2) { 55 | isZoomCompleteRef.current = true 56 | scaleApi.start({ scale: 1 }) 57 | } 58 | } else { 59 | ref.current.position.lerp(initialPositionVector, 0.1) 60 | if (isZoomCompleteRef.current) { 61 | isZoomCompleteRef.current = false 62 | scaleApi.start({ scale: 0 }) 63 | } 64 | } 65 | }) 66 | return ( 67 | { 71 | setSelectedIndex(id) 72 | }} 73 | > 74 | 75 | 76 | 77 | 78 | {isSelected && ( 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | 89 | 90 | ) 91 | } 92 | 93 | export default React.memo(SphereItem) 94 | -------------------------------------------------------------------------------- /src/components/Controls/Controls.jsx: -------------------------------------------------------------------------------- 1 | import Joystick from './Joystick/Joystick' 2 | import Slider from './Slider/Slider' 3 | import Toggle from './Toggle/Toggle' 4 | 5 | const Controls = () => { 6 | return ( 7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 | ) 15 | } 16 | 17 | export default Controls 18 | -------------------------------------------------------------------------------- /src/components/Controls/Joystick/Joystick.jsx: -------------------------------------------------------------------------------- 1 | import { WidthIcon } from '@radix-ui/react-icons' 2 | import { animated as a, useSpring } from '@react-spring/web' 3 | import { useDrag } from '@use-gesture/react' 4 | import { useRef } from 'react' 5 | import { useStore } from '../../../App' 6 | 7 | const Joystick = () => { 8 | const [{ x }, api] = useSpring(() => ({ x: 0 })) 9 | const containerRef = useRef() 10 | const { left, right } = useStore((state) => state.sphereControls) 11 | const setControls = useStore((state) => state.setControls) 12 | const resetControls = useStore((state) => state.resetControls) 13 | 14 | const bind = useDrag( 15 | ({ down, movement: [mx], direction: [dx] }) => { 16 | if (Math.abs(mx) > 10 && !left && !right) { 17 | setControls({ left: dx < 0, right: dx > 0 }) 18 | } 19 | if (!down && (left || right)) { 20 | resetControls() 21 | } 22 | api.start({ x: down ? mx : 0, immediate: down }) 23 | }, 24 | { bounds: containerRef } 25 | ) 26 | 27 | return ( 28 |
29 |
34 | 35 |
36 | 37 |
38 | ) 39 | } 40 | 41 | export default Joystick 42 | -------------------------------------------------------------------------------- /src/components/Controls/Slider/Slider.jsx: -------------------------------------------------------------------------------- 1 | import { StarFilledIcon } from '@radix-ui/react-icons' 2 | import * as Slider from '@radix-ui/react-slider' 3 | import { useStore } from '../../../App' 4 | 5 | const SliderComponent = () => { 6 | const ratingFilter = useStore((state) => state.ratingFilter) 7 | const setRatingFilter = useStore((state) => state.setRatingFilter) 8 | return ( 9 |
10 | 18 | 19 | 20 | 21 | 25 | 26 |
27 | 28 |
29 | {'>'} 30 | {ratingFilter[0]} 31 |
32 |
33 |
34 | ) 35 | } 36 | 37 | export default SliderComponent 38 | -------------------------------------------------------------------------------- /src/components/Controls/Toggle/Toggle.jsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@headlessui/react' 2 | import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons' 3 | import { useStore } from '../../../App' 4 | 5 | const Toggle = () => { 6 | const cardHidden = useStore((state) => state.cardHidden) 7 | const setCardHidden = useStore((state) => state.setCardHidden) 8 | return ( 9 |
10 |
11 | 17 | Card hidden 18 | 24 |
25 | {cardHidden ? : } 26 |
27 | ) 28 | } 29 | 30 | export default Toggle 31 | -------------------------------------------------------------------------------- /src/components/GameCard/GameContent.jsx: -------------------------------------------------------------------------------- 1 | import { animated as a, config, useChain, useSpring, useSpringRef } from '@react-spring/web' 2 | import { LoremIpsum, loremIpsum } from 'lorem-ipsum' 3 | import React, { useState } from 'react' 4 | import { getRandomRating, getRandomTags } from '../../utils' 5 | 6 | const lorem = new LoremIpsum({ 7 | wordsPerSentence: { 8 | max: 16, 9 | min: 4, 10 | }, 11 | }) 12 | 13 | const GameContent = ({ id, playerRating, pressRating }) => { 14 | const springRate1Ref = useSpringRef() 15 | const springRate2Ref = useSpringRef() 16 | const [introText] = useState(lorem.generateSentences(1)) 17 | const [descriptionText] = useState(lorem.generateSentences(4)) 18 | const { rate1 } = useSpring({ 19 | from: { rate1: 0 }, 20 | to: { rate1: pressRating }, 21 | config: config.slow, 22 | ref: springRate1Ref, 23 | delay: 2000, 24 | }) 25 | const { rate2 } = useSpring({ from: { rate2: 0 }, to: { rate2: playerRating }, config: config.slow, ref: springRate2Ref }) 26 | useChain([springRate1Ref, springRate2Ref]) 27 | const tags = getRandomTags() 28 | 29 | return ( 30 |
31 |
32 |
33 |

Game {id}

34 |
35 | {tags.map((tag, index) => ( 36 | 40 | {tag.label} 41 | 42 | ))} 43 |
44 |
{introText}
45 |
{descriptionText}
46 |
47 |
48 |

Press

49 |

50 | {rate1.to((v) => v.toFixed(0))} 51 | /20 52 |

53 |
54 |
55 |

Players

56 |

57 | {rate2.to((v) => v.toFixed(0))} 58 | /20 59 |

60 |
61 |
62 | 63 |
64 |
65 |
66 | ) 67 | } 68 | 69 | export default React.memo(GameContent) 70 | -------------------------------------------------------------------------------- /src/components/GameCard/GameCover.jsx: -------------------------------------------------------------------------------- 1 | import { animated as a, useSpring } from '@react-spring/three' 2 | import { Image } from '@react-three/drei' 3 | import { useRef } from 'react' 4 | 5 | const GameCover = ({ id, isSelected }) => { 6 | const imageIndex = id % 54 7 | const imageUrl = `images/games/${imageIndex}.jpeg` 8 | const { scale } = useSpring({ scale: isSelected ? [2, 2, 1] : [1, 1, 1] }) 9 | const ref = useRef() 10 | const imageRef = useRef() 11 | 12 | return ( 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default GameCover 20 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const CARDS_NUMBER = 100 2 | export const DEFAULT_CARD = 50 3 | -------------------------------------------------------------------------------- /src/hooks/useControlsUpdate.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useStore } from '../App' 3 | import { DEFAULT_CARD } from '../constants' 4 | import { getNewIndex } from '../utils' 5 | 6 | const useControlsUpdate = ({ data }) => { 7 | const selectedIndex = useStore((state) => state.selectedIndex) 8 | const setSelectedIndex = useStore((state) => state.setSelectedIndex) 9 | const { left, right } = useStore((state) => state.sphereControls) 10 | 11 | useEffect(() => { 12 | let controlsTimeout 13 | const updateControl = (index) => { 14 | controlsTimeout = setTimeout(() => { 15 | const newIndex = getNewIndex({ direction: left ? -1 : 1, currentIndex: index, data }) 16 | setSelectedIndex(newIndex) 17 | if (right || left) { 18 | updateControl(newIndex) 19 | } 20 | }, 500) 21 | } 22 | 23 | if (left || right) { 24 | const newIndex = 25 | selectedIndex !== null 26 | ? getNewIndex({ direction: left ? -1 : 1, currentIndex: useStore.getState().selectedIndex, data }) 27 | : DEFAULT_CARD 28 | setSelectedIndex(newIndex) 29 | updateControl(newIndex) 30 | } 31 | 32 | return () => clearInterval(controlsTimeout) 33 | }, [left, right, data]) 34 | 35 | return null 36 | } 37 | 38 | export default useControlsUpdate 39 | -------------------------------------------------------------------------------- /src/hooks/useInitialPosition.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { getElementPosition } from '../utils' 3 | 4 | const useInitialPosition = ({ itemRef, cardIndex, initialPositionVector, cameraVector }) => { 5 | useEffect(() => { 6 | if (itemRef.current !== undefined) { 7 | const { radius, phi, theta } = getElementPosition({ index: cardIndex }) 8 | itemRef.current.position.setFromSphericalCoords(radius, phi, theta) 9 | initialPositionVector.copy(itemRef.current.position) 10 | cameraVector.copy(itemRef.current.position).multiplyScalar(-2) 11 | itemRef.current.lookAt(cameraVector) 12 | } 13 | }, [cardIndex]) 14 | 15 | return null 16 | } 17 | 18 | export default useInitialPosition 19 | -------------------------------------------------------------------------------- /src/hooks/useKeyboardControls.jsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '../App' 2 | 3 | const useKeyboardControls = () => { 4 | const { left, right } = useStore((state) => state.sphereControls) 5 | const setRight = useStore((state) => state.setRight) 6 | const setLeft = useStore((state) => state.setLeft) 7 | const resetControls = useStore((state) => state.resetControls) 8 | 9 | const handleKeyDown = (event) => { 10 | if (event.key === 'ArrowLeft' && !left) { 11 | setLeft() 12 | } else if (event.key === 'ArrowRight' && !right) { 13 | setRight() 14 | } 15 | } 16 | 17 | const handleKeyUp = (event) => { 18 | if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { 19 | resetControls() 20 | } 21 | } 22 | 23 | return { handleKeyDown, handleKeyUp } 24 | } 25 | 26 | export default useKeyboardControls 27 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | const useMediaQuery = (query) => { 4 | const [matches, setMatches] = useState(false) 5 | 6 | useEffect(() => { 7 | const media = window.matchMedia(query) 8 | if (media.matches !== matches) { 9 | setMatches(media.matches) 10 | } 11 | const listener = () => setMatches(media.matches) 12 | window.addEventListener('resize', listener) 13 | return () => window.removeEventListener('resize', listener) 14 | }, [matches, query]) 15 | 16 | return matches 17 | } 18 | 19 | export default useMediaQuery 20 | -------------------------------------------------------------------------------- /src/hooks/useSelectedPosition.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | const useSelectedPosition = ({ isSelected, zoomPositionVector, selectedVector, tempVector, translationVector, itemRef }) => { 4 | useEffect(() => { 5 | let vectorUpdateTimeout 6 | if (isSelected) { 7 | zoomPositionVector.copy(itemRef.current.position) 8 | selectedVector.copy(itemRef.current.position).multiplyScalar(-0.5) 9 | vectorUpdateTimeout = setTimeout(() => { 10 | zoomPositionVector.copy(itemRef.current.position).multiplyScalar(0) 11 | tempVector.copy(translationVector) 12 | tempVector.transformDirection(itemRef.current.matrixWorld).normalize().multiplyScalar(1.7) 13 | zoomPositionVector.add(tempVector) 14 | }, 500) 15 | } 16 | return () => clearTimeout(vectorUpdateTimeout) 17 | }, [isSelected]) 18 | } 19 | 20 | export default useSelectedPosition 21 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ) 11 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | -webkit-tap-highlight-color: rgba(255, 255, 255, 0); 13 | } 14 | 15 | body { 16 | font-family: system-ui, -apple-system, /* Firefox supports this but not yet `system-ui` */ 'Segoe UI', Roboto, Helvetica, Arial, 17 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { CARDS_NUMBER } from './constants' 2 | 3 | const radius = 8 4 | 5 | const getRandomRating = () => Math.round(Math.random() * 20) 6 | 7 | const getData = () => 8 | Array.from({ length: CARDS_NUMBER }, (value, key) => ({ id: key, playerRating: getRandomRating(), pressRating: getRandomRating() })) 9 | 10 | const getElementPosition = ({ index }) => { 11 | const phi = Math.acos(-1 + (2 * index) / CARDS_NUMBER) 12 | const theta = Math.sqrt(CARDS_NUMBER * Math.PI) * phi 13 | 14 | return { radius, phi, theta } 15 | } 16 | 17 | const getRandomTags = () => { 18 | const tags = [ 19 | { color: 'blue', label: 'RPG' }, 20 | { color: 'red', label: 'FPS' }, 21 | { color: 'green', label: 'ADVENTURE' }, 22 | { color: 'purple', label: 'ACTION' }, 23 | { color: 'indigo', label: 'RTS' }, 24 | { color: 'gray', label: 'MOBA' }, 25 | ] 26 | 27 | const randomTagIndex1 = Math.floor(Math.random() * tags.length) 28 | const randomTagIndex2 = (randomTagIndex1 + 1) % tags.length 29 | 30 | return [tags[randomTagIndex1], tags[randomTagIndex2]] 31 | } 32 | 33 | const getNewIndex = ({ data, direction, currentIndex }) => { 34 | const minSlice = direction < 0 ? 0 : currentIndex + 1 35 | const maxSlice = direction < 0 ? currentIndex : data.length 36 | const dataSliced = data.slice(minSlice, maxSlice) 37 | const dataSort = direction < 0 ? dataSliced.reverse() : dataSliced 38 | return dataSort.find((element) => !element.filtered)?.id || currentIndex 39 | } 40 | 41 | export { getElementPosition, getRandomRating, getRandomTags, getData, getNewIndex } 42 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | darkMode: false, 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | safelist: 'bg-indigo-600 bg-blue-600 bg-green-600 bg-purple-600 bg-red-600 bg-gray-600', 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import WindiCSS from 'vite-plugin-windicss' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), WindiCSS()], 8 | }) 9 | --------------------------------------------------------------------------------