├── .gitignore ├── README.md ├── index.html ├── package.json ├── public └── vite.svg ├── src ├── App.jsx ├── assets │ └── react.svg ├── components │ ├── Cube.jsx │ ├── Cubes.jsx │ ├── FPV.jsx │ ├── Ground.jsx │ ├── Player.jsx │ └── TextureSelect.jsx ├── hooks │ ├── useKeyboard.js │ └── useStore.js ├── images │ ├── dirt.jpg │ ├── glass.png │ ├── grass.jpg │ ├── images.js │ ├── log.jpg │ ├── textures.js │ └── wood.png ├── index.css └── main.jsx └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minecraft Clone 2 | Clon de [Minecraft](https://minecraft.net/) hecho con 3 | 4 | - [React](https://reactjs.org/) 5 | - [ThreeJS](https://threejs.org/) (Libreria de 3D para JS) 6 | - [@react-three/cannon](https://cannon.pmnd.rs/) 7 | - [@react-three/drei](https://drei.pmnd.rs/) 8 | - [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/) 9 | - [ViteJS](https://vitejs.dev) (Empaquetador) 10 | 11 | Basado en el vídeo de [FreecodeCamp](https://youtube.com/watch?v=qpOZup_3P_A&t=0s) 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minecraft-clone", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@react-three/cannon": "6.5.0", 13 | "@react-three/drei": "9.40.0", 14 | "@react-three/fiber": "8.9.1", 15 | "nanoid": "4.0.0", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0", 18 | "three": "0.146.0", 19 | "zustand": "4.1.4" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "18.0.24", 23 | "@types/react-dom": "18.0.8", 24 | "@vitejs/plugin-react": "2.2.0", 25 | "standard": "17.0.0", 26 | "vite": "3.2.3" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "./node_modules/standard/eslintrc.json" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Canvas } from '@react-three/fiber' 2 | import { Sky } from '@react-three/drei' 3 | import { Physics } from '@react-three/cannon' 4 | import { Ground } from './components/Ground.jsx' 5 | import { FPV as Fpv } from './components/FPV.jsx' 6 | import { Player } from './components/Player.jsx' 7 | import { Cubes } from './components/Cubes.jsx' 8 | import { TextureSelector } from './components/TextureSelect.jsx' 9 | 10 | function App () { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
+
25 | 26 | 27 | ) 28 | } 29 | 30 | export default App 31 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Cube.jsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '../hooks/useStore.js' 2 | import { useBox } from '@react-three/cannon' 3 | import { useState } from 'react' 4 | import * as textures from '../images/textures.js' 5 | 6 | export const Cube = ({ id, position, texture }) => { 7 | const [isHovered, setIsHovered] = useState(false) 8 | const [removeCube] = useStore(state => [state.removeCube]) 9 | 10 | const [ref] = useBox(() => ({ 11 | type: 'Static', 12 | position 13 | })) 14 | 15 | const activeTexture = textures[texture + 'Texture'] 16 | 17 | return ( 18 | { 20 | e.stopPropagation() 21 | setIsHovered(true) 22 | }} 23 | onPointerOut={(e) => { 24 | e.stopPropagation() 25 | setIsHovered(false) 26 | }} 27 | ref={ref} 28 | onClick={(e) => { 29 | e.stopPropagation() 30 | 31 | if (e.altKey) { 32 | removeCube(id) 33 | } 34 | }} 35 | > 36 | 37 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Cubes.jsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '../hooks/useStore' 2 | import { Cube } from './Cube.jsx' 3 | 4 | export const Cubes = () => { 5 | const [cubes] = useStore(state => [state.cubes]) 6 | 7 | return cubes.map(({ id, pos, texture }) => { 8 | return ( 9 | 15 | ) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/FPV.jsx: -------------------------------------------------------------------------------- 1 | import { PointerLockControls } from '@react-three/drei' 2 | import { useThree } from '@react-three/fiber' 3 | 4 | export function FPV () { 5 | const { camera, gl } = useThree() 6 | 7 | return ( 8 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Ground.jsx: -------------------------------------------------------------------------------- 1 | import { usePlane } from '@react-three/cannon' 2 | import { useStore } from '../hooks/useStore.js' 3 | import { groundTexture } from '../images/textures.js' 4 | 5 | export function Ground () { 6 | const [ref] = usePlane(() => ({ 7 | rotation: [-Math.PI / 2, 0, 0], 8 | position: [0, -0.5, 0] 9 | })) 10 | 11 | const [addCube] = useStore(state => [state.addCube]) 12 | 13 | groundTexture.repeat.set(100, 100) 14 | 15 | const handleClickGround = event => { 16 | event.stopPropagation() 17 | const [x, y, z] = Object.values(event.point) 18 | .map(n => Math.ceil(n)) 19 | 20 | addCube(x, y, z) 21 | } 22 | 23 | return ( 24 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Player.jsx: -------------------------------------------------------------------------------- 1 | import { useSphere } from '@react-three/cannon' 2 | import { useFrame, useThree } from '@react-three/fiber' 3 | import { useEffect, useRef } from 'react' 4 | import { Vector3 } from 'three' 5 | import { useKeyboard } from '../hooks/useKeyboard.js' 6 | 7 | const CHARACTER_SPEED = 4 8 | const CHARACTER_JUMP_FORCE = 4 9 | 10 | export const Player = () => { 11 | const { 12 | moveBackward, 13 | moveForward, 14 | moveLeft, 15 | moveRight, 16 | jump 17 | } = useKeyboard() 18 | 19 | const { camera } = useThree() 20 | const [ref, api] = useSphere(() => ({ 21 | mass: 1, 22 | type: 'Dynamic', 23 | position: [0, 0.5, 0] 24 | })) 25 | 26 | const pos = useRef([0, 0, 0]) 27 | useEffect(() => { 28 | api.position.subscribe(p => { 29 | pos.current = p 30 | }) 31 | }, [api.position]) 32 | 33 | const vel = useRef([0, 0, 0]) 34 | useEffect(() => { 35 | api.velocity.subscribe(p => { 36 | vel.current = p 37 | }) 38 | }, [api.velocity]) 39 | 40 | useFrame(() => { 41 | camera.position.copy( 42 | new Vector3( 43 | pos.current[0], // x 44 | pos.current[1], // y 45 | pos.current[2] // z 46 | ) 47 | ) 48 | 49 | const direction = new Vector3() 50 | 51 | const frontVector = new Vector3( 52 | 0, 53 | 0, 54 | (moveBackward ? 1 : 0) - (moveForward ? 1 : 0) 55 | ) 56 | 57 | const sideVector = new Vector3( 58 | (moveLeft ? 1 : 0) - (moveRight ? 1 : 0), 59 | 0, 60 | 0 61 | ) 62 | 63 | direction 64 | .subVectors(frontVector, sideVector) 65 | .normalize() 66 | .multiplyScalar(CHARACTER_SPEED) // walk: 2, run: 5 67 | .applyEuler(camera.rotation) 68 | 69 | api.velocity.set( 70 | direction.x, 71 | vel.current[1], // ???? saltar. 72 | direction.z 73 | ) 74 | 75 | if (jump && Math.abs(vel.current[1]) < 0.05) { 76 | api.velocity.set( 77 | vel.current[0], 78 | CHARACTER_JUMP_FORCE, 79 | vel.current[2] 80 | ) 81 | } 82 | }) 83 | 84 | return ( 85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/components/TextureSelect.jsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '../hooks/useStore.js' 2 | import * as images from '../images/images.js' 3 | import { useKeyboard } from '../hooks/useKeyboard.js' 4 | import { useEffect, useState } from 'react' 5 | 6 | export const TextureSelector = () => { 7 | const [visible, setVisible] = useState(true) 8 | const [texture, setTexture] = useStore(state => [state.texture, state.setTexture]) 9 | 10 | const { 11 | dirt, 12 | grass, 13 | glass, 14 | wood, 15 | log 16 | } = useKeyboard() 17 | 18 | useEffect(() => { 19 | const visibilityTimeout = setTimeout(() => { 20 | setVisible(false) 21 | }, 1000) 22 | 23 | setVisible(true) 24 | 25 | return () => { 26 | clearTimeout(visibilityTimeout) 27 | } 28 | }, [texture]) 29 | 30 | useEffect(() => { 31 | const options = { 32 | dirt, 33 | grass, 34 | glass, 35 | wood, 36 | log 37 | } 38 | 39 | const selectedTexture = Object 40 | .entries(options) 41 | .find(([texture, isEnabled]) => isEnabled) 42 | 43 | if (selectedTexture) { 44 | const [textureName] = selectedTexture 45 | setTexture(textureName) 46 | } 47 | }, [dirt, grass, glass, wood, log]) 48 | 49 | return ( 50 |
51 | { 52 | Object 53 | .entries(images) 54 | .map(([imgKey, img]) => { 55 | return ( 56 | {imgKey} 62 | ) 63 | }) 64 | } 65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/hooks/useKeyboard.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const ACTIONS_KEYBOARD_MAP = { 4 | KeyW: 'moveForward', 5 | KeyS: 'moveBackward', 6 | KeyA: 'moveLeft', 7 | KeyD: 'moveRight', 8 | Space: 'jump', 9 | Digit1: 'dirt', 10 | Digit2: 'grass', 11 | Digit3: 'glass', 12 | Digit4: 'wood', 13 | Digit5: 'log' 14 | } 15 | 16 | export const useKeyboard = () => { 17 | const [actions, setActions] = useState({ 18 | moveForward: false, 19 | moveBackward: false, 20 | moveLeft: false, 21 | moveRight: false, 22 | jump: false, 23 | dirt: false, 24 | grass: false, 25 | glass: false, 26 | wood: false, 27 | log: false 28 | }) 29 | 30 | useEffect(() => { 31 | const handleKeyDown = event => { 32 | const { code } = event 33 | const action = ACTIONS_KEYBOARD_MAP[code] 34 | 35 | if (action) { 36 | // if (actions[action]) return 37 | 38 | setActions(prevActions => ({ 39 | ...prevActions, 40 | [action]: true 41 | })) 42 | } 43 | } 44 | 45 | const handleKeyUp = event => { 46 | const { code } = event 47 | const action = ACTIONS_KEYBOARD_MAP[code] 48 | 49 | if (action) { 50 | // if (!actions[action]) return 51 | 52 | setActions(prevActions => ({ 53 | ...prevActions, 54 | [action]: false 55 | })) 56 | } 57 | } 58 | 59 | document.addEventListener('keydown', handleKeyDown) 60 | document.addEventListener('keyup', handleKeyUp) 61 | 62 | return () => { 63 | document.removeEventListener('keydown', handleKeyDown) 64 | document.removeEventListener('keyup', handleKeyUp) 65 | } 66 | }, []) 67 | 68 | return actions 69 | } 70 | -------------------------------------------------------------------------------- /src/hooks/useStore.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import create from 'zustand' 3 | 4 | export const useStore = create(set => ({ 5 | texture: 'dirt', 6 | cubes: [{ 7 | id: nanoid(), 8 | pos: [1, 1, 1], 9 | texture: 'dirt' 10 | }, { 11 | id: nanoid(), 12 | pos: [1, 5, 1], 13 | texture: 'log' 14 | }], 15 | addCube: (x, y, z) => { 16 | set(state => ({ 17 | cubes: [...state.cubes, { 18 | id: nanoid(), 19 | texture: state.texture, 20 | pos: [x, y, z] 21 | }] 22 | })) 23 | }, 24 | removeCube: (id) => { 25 | set(state => ({ 26 | cubes: state.cubes.filter(cube => cube.id !== id) 27 | })) 28 | }, 29 | setTexture: (texture) => { 30 | set(() => ({ texture })) 31 | }, 32 | saveWorld: () => {}, 33 | resetWorld: () => {} 34 | })) 35 | -------------------------------------------------------------------------------- /src/images/dirt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/minecraft-clone/36a1323dea6be83e75eed5795d379fd5c6591031/src/images/dirt.jpg -------------------------------------------------------------------------------- /src/images/glass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/minecraft-clone/36a1323dea6be83e75eed5795d379fd5c6591031/src/images/glass.png -------------------------------------------------------------------------------- /src/images/grass.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/minecraft-clone/36a1323dea6be83e75eed5795d379fd5c6591031/src/images/grass.jpg -------------------------------------------------------------------------------- /src/images/images.js: -------------------------------------------------------------------------------- 1 | export { default as grassImg } from './grass.jpg' 2 | export { default as glassImg } from './glass.png' 3 | export { default as dirtImg } from './dirt.jpg' 4 | export { default as logImg } from './log.jpg' 5 | export { default as woodImg } from './wood.png' 6 | -------------------------------------------------------------------------------- /src/images/log.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/minecraft-clone/36a1323dea6be83e75eed5795d379fd5c6591031/src/images/log.jpg -------------------------------------------------------------------------------- /src/images/textures.js: -------------------------------------------------------------------------------- 1 | import { 2 | grassImg, 3 | dirtImg, 4 | logImg, 5 | glassImg, 6 | woodImg 7 | } from './images.js' 8 | 9 | import { NearestFilter, RepeatWrapping, TextureLoader } from 'three' 10 | 11 | const grassTexture = new TextureLoader().load(grassImg) 12 | const dirtTexture = new TextureLoader().load(dirtImg) 13 | const logTexture = new TextureLoader().load(logImg) 14 | const glassTexture = new TextureLoader().load(glassImg) 15 | const woodTexture = new TextureLoader().load(woodImg) 16 | 17 | const groundTexture = new TextureLoader().load(grassImg) 18 | 19 | groundTexture.wrapS = RepeatWrapping 20 | groundTexture.wrapT = RepeatWrapping 21 | 22 | groundTexture.magFilter = NearestFilter 23 | grassTexture.magFilter = NearestFilter 24 | dirtTexture.magFilter = NearestFilter 25 | logTexture.magFilter = NearestFilter 26 | glassTexture.magFilter = NearestFilter 27 | woodTexture.magFilter = NearestFilter 28 | 29 | export { 30 | groundTexture, 31 | grassTexture, 32 | dirtTexture, 33 | logTexture, 34 | glassTexture, 35 | woodTexture 36 | } 37 | -------------------------------------------------------------------------------- /src/images/wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/minecraft-clone/36a1323dea6be83e75eed5795d379fd5c6591031/src/images/wood.png -------------------------------------------------------------------------------- /src/index.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 | user-select: none; 13 | overflow: hidden; 14 | } 15 | 16 | body { 17 | position: fixed; 18 | height: 100vh; 19 | width: 100vw; 20 | overflow: hidden; 21 | top: 0; 22 | bottom: 0; 23 | left: 0; 24 | right: 0; 25 | } 26 | 27 | .pointer { 28 | color: white; 29 | font-size: 40px; 30 | position: absolute; 31 | top: 50%; 32 | left: 50%; 33 | transform: translate(-50%, -50%); 34 | opacity: .5; 35 | z-index: 0; 36 | } 37 | 38 | .texture-selector { 39 | bottom: 32px; 40 | background: #aaa; 41 | padding: 8px; 42 | border: 3px solid #000; 43 | position: absolute; 44 | left: 50%; 45 | transform: translateX(-50%); 46 | display: flex; 47 | gap: 8px; 48 | } 49 | 50 | .texture-selector.hidden { 51 | display: none; 52 | } 53 | 54 | .texture-selector img { 55 | width: 50px; 56 | z-index: 100; 57 | image-rendering: pixelated; 58 | border: 3px solid #000; 59 | } 60 | 61 | .texture-selector img.selected { 62 | border-color: red; 63 | } 64 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | --------------------------------------------------------------------------------