├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public ├── fonts │ └── Minecraft.ttf ├── models │ └── pickaxe.glb ├── textures │ ├── cactus_side.png │ ├── cactus_top.png │ ├── coal_ore.png │ ├── dirt.png │ ├── grass.png │ ├── grass_side.png │ ├── iron_ore.png │ ├── jungle_leaves.png │ ├── jungle_tree_side.png │ ├── jungle_tree_top.png │ ├── leaves.png │ ├── pickaxe.png │ ├── sand.png │ ├── snow.png │ ├── snow_side.png │ ├── stone.png │ ├── tree_side.png │ └── tree_top.png └── vite.svg ├── scripts ├── blocks.js ├── dataStore.js ├── main.js ├── modelLoader.js ├── physics.js ├── player.js ├── rng.js ├── ui.js ├── world.js └── worldChunk.js ├── style.css └── vite.config.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Set up Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | cache: 'npm' 38 | - name: Install dependencies 39 | run: npm install 40 | - name: Build 41 | run: npm run build 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v3 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v1 46 | with: 47 | # Upload dist repository 48 | path: './dist' 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v1 52 | -------------------------------------------------------------------------------- /.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-threejs-clone 2 | 3 | A simple Minecraft clone built with Three.js. 4 | 5 | ![Screenshot 2023-09-29 at 12 14 34 AM](https://github.com/dgreenheck/minecraft-threejs-clone/assets/3814912/c39c6b2a-f6e4-4f43-824c-d0e727539170) 6 | 7 | ## Live Demo 8 | 9 | https://dgreenheck.github.io/minecraft-threejs-clone/ 10 | 11 | ## Project Goal 12 | 13 | Teach developers how to build a Minecraft clone that runs in the browser without using advanced graphics techniques or writing custom shader code. 14 | 15 | ## How did you make this? Can I learn? 16 | 17 | You sure can! I created a tutorial video series on my YouTube channel which you can check out [here](https://www.youtube.com/playlist?list=PLtzt35QOXmkKALLv9RzT8oGwN5qwmRjTo). 18 | 19 | ## Features 20 | - Procedural World Generation 21 | - Biomes 22 | - Resources (Coal and Iron) 23 | - Terrain Chunking 24 | - Terraforming 25 | - Save/Load 26 | 27 | ## ToDo List 28 | - Inventory Management 29 | - Crafting 30 | - More block types 31 | - Item drops 32 | - NPCs 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Minecraft.js 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 |
32 |

MINECRAFTjs

33 | WASD - Move
34 | SHIFT - Sprint
35 | SPACE - Jump
36 | R - Reset Camera
37 | U - Toggle UI 38 | 0 - Pickaxe
39 | 1-8 - Select Block
40 | F1 - Save Game
41 | F2 - Load Game
42 | F10 - Debug Camera

43 |

PRESS ANY KEY TO START

44 |
45 |
46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minecraft-test-2", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "minecraft-test-2", 9 | "version": "0.0.0", 10 | "dependencies": { 11 | "three": "^0.156.1" 12 | }, 13 | "devDependencies": { 14 | "vite": "^4.4.5" 15 | } 16 | }, 17 | "node_modules/@esbuild/android-arm": { 18 | "version": "0.18.20", 19 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", 20 | "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", 21 | "cpu": [ 22 | "arm" 23 | ], 24 | "dev": true, 25 | "optional": true, 26 | "os": [ 27 | "android" 28 | ], 29 | "engines": { 30 | "node": ">=12" 31 | } 32 | }, 33 | "node_modules/@esbuild/android-arm64": { 34 | "version": "0.18.20", 35 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", 36 | "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", 37 | "cpu": [ 38 | "arm64" 39 | ], 40 | "dev": true, 41 | "optional": true, 42 | "os": [ 43 | "android" 44 | ], 45 | "engines": { 46 | "node": ">=12" 47 | } 48 | }, 49 | "node_modules/@esbuild/android-x64": { 50 | "version": "0.18.20", 51 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", 52 | "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", 53 | "cpu": [ 54 | "x64" 55 | ], 56 | "dev": true, 57 | "optional": true, 58 | "os": [ 59 | "android" 60 | ], 61 | "engines": { 62 | "node": ">=12" 63 | } 64 | }, 65 | "node_modules/@esbuild/darwin-arm64": { 66 | "version": "0.18.20", 67 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", 68 | "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", 69 | "cpu": [ 70 | "arm64" 71 | ], 72 | "dev": true, 73 | "optional": true, 74 | "os": [ 75 | "darwin" 76 | ], 77 | "engines": { 78 | "node": ">=12" 79 | } 80 | }, 81 | "node_modules/@esbuild/darwin-x64": { 82 | "version": "0.18.20", 83 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", 84 | "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", 85 | "cpu": [ 86 | "x64" 87 | ], 88 | "dev": true, 89 | "optional": true, 90 | "os": [ 91 | "darwin" 92 | ], 93 | "engines": { 94 | "node": ">=12" 95 | } 96 | }, 97 | "node_modules/@esbuild/freebsd-arm64": { 98 | "version": "0.18.20", 99 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", 100 | "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", 101 | "cpu": [ 102 | "arm64" 103 | ], 104 | "dev": true, 105 | "optional": true, 106 | "os": [ 107 | "freebsd" 108 | ], 109 | "engines": { 110 | "node": ">=12" 111 | } 112 | }, 113 | "node_modules/@esbuild/freebsd-x64": { 114 | "version": "0.18.20", 115 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", 116 | "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", 117 | "cpu": [ 118 | "x64" 119 | ], 120 | "dev": true, 121 | "optional": true, 122 | "os": [ 123 | "freebsd" 124 | ], 125 | "engines": { 126 | "node": ">=12" 127 | } 128 | }, 129 | "node_modules/@esbuild/linux-arm": { 130 | "version": "0.18.20", 131 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", 132 | "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", 133 | "cpu": [ 134 | "arm" 135 | ], 136 | "dev": true, 137 | "optional": true, 138 | "os": [ 139 | "linux" 140 | ], 141 | "engines": { 142 | "node": ">=12" 143 | } 144 | }, 145 | "node_modules/@esbuild/linux-arm64": { 146 | "version": "0.18.20", 147 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", 148 | "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", 149 | "cpu": [ 150 | "arm64" 151 | ], 152 | "dev": true, 153 | "optional": true, 154 | "os": [ 155 | "linux" 156 | ], 157 | "engines": { 158 | "node": ">=12" 159 | } 160 | }, 161 | "node_modules/@esbuild/linux-ia32": { 162 | "version": "0.18.20", 163 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", 164 | "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", 165 | "cpu": [ 166 | "ia32" 167 | ], 168 | "dev": true, 169 | "optional": true, 170 | "os": [ 171 | "linux" 172 | ], 173 | "engines": { 174 | "node": ">=12" 175 | } 176 | }, 177 | "node_modules/@esbuild/linux-loong64": { 178 | "version": "0.18.20", 179 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", 180 | "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", 181 | "cpu": [ 182 | "loong64" 183 | ], 184 | "dev": true, 185 | "optional": true, 186 | "os": [ 187 | "linux" 188 | ], 189 | "engines": { 190 | "node": ">=12" 191 | } 192 | }, 193 | "node_modules/@esbuild/linux-mips64el": { 194 | "version": "0.18.20", 195 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", 196 | "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", 197 | "cpu": [ 198 | "mips64el" 199 | ], 200 | "dev": true, 201 | "optional": true, 202 | "os": [ 203 | "linux" 204 | ], 205 | "engines": { 206 | "node": ">=12" 207 | } 208 | }, 209 | "node_modules/@esbuild/linux-ppc64": { 210 | "version": "0.18.20", 211 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", 212 | "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", 213 | "cpu": [ 214 | "ppc64" 215 | ], 216 | "dev": true, 217 | "optional": true, 218 | "os": [ 219 | "linux" 220 | ], 221 | "engines": { 222 | "node": ">=12" 223 | } 224 | }, 225 | "node_modules/@esbuild/linux-riscv64": { 226 | "version": "0.18.20", 227 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", 228 | "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", 229 | "cpu": [ 230 | "riscv64" 231 | ], 232 | "dev": true, 233 | "optional": true, 234 | "os": [ 235 | "linux" 236 | ], 237 | "engines": { 238 | "node": ">=12" 239 | } 240 | }, 241 | "node_modules/@esbuild/linux-s390x": { 242 | "version": "0.18.20", 243 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", 244 | "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", 245 | "cpu": [ 246 | "s390x" 247 | ], 248 | "dev": true, 249 | "optional": true, 250 | "os": [ 251 | "linux" 252 | ], 253 | "engines": { 254 | "node": ">=12" 255 | } 256 | }, 257 | "node_modules/@esbuild/linux-x64": { 258 | "version": "0.18.20", 259 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", 260 | "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", 261 | "cpu": [ 262 | "x64" 263 | ], 264 | "dev": true, 265 | "optional": true, 266 | "os": [ 267 | "linux" 268 | ], 269 | "engines": { 270 | "node": ">=12" 271 | } 272 | }, 273 | "node_modules/@esbuild/netbsd-x64": { 274 | "version": "0.18.20", 275 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", 276 | "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", 277 | "cpu": [ 278 | "x64" 279 | ], 280 | "dev": true, 281 | "optional": true, 282 | "os": [ 283 | "netbsd" 284 | ], 285 | "engines": { 286 | "node": ">=12" 287 | } 288 | }, 289 | "node_modules/@esbuild/openbsd-x64": { 290 | "version": "0.18.20", 291 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", 292 | "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", 293 | "cpu": [ 294 | "x64" 295 | ], 296 | "dev": true, 297 | "optional": true, 298 | "os": [ 299 | "openbsd" 300 | ], 301 | "engines": { 302 | "node": ">=12" 303 | } 304 | }, 305 | "node_modules/@esbuild/sunos-x64": { 306 | "version": "0.18.20", 307 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", 308 | "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", 309 | "cpu": [ 310 | "x64" 311 | ], 312 | "dev": true, 313 | "optional": true, 314 | "os": [ 315 | "sunos" 316 | ], 317 | "engines": { 318 | "node": ">=12" 319 | } 320 | }, 321 | "node_modules/@esbuild/win32-arm64": { 322 | "version": "0.18.20", 323 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", 324 | "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", 325 | "cpu": [ 326 | "arm64" 327 | ], 328 | "dev": true, 329 | "optional": true, 330 | "os": [ 331 | "win32" 332 | ], 333 | "engines": { 334 | "node": ">=12" 335 | } 336 | }, 337 | "node_modules/@esbuild/win32-ia32": { 338 | "version": "0.18.20", 339 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", 340 | "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", 341 | "cpu": [ 342 | "ia32" 343 | ], 344 | "dev": true, 345 | "optional": true, 346 | "os": [ 347 | "win32" 348 | ], 349 | "engines": { 350 | "node": ">=12" 351 | } 352 | }, 353 | "node_modules/@esbuild/win32-x64": { 354 | "version": "0.18.20", 355 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", 356 | "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", 357 | "cpu": [ 358 | "x64" 359 | ], 360 | "dev": true, 361 | "optional": true, 362 | "os": [ 363 | "win32" 364 | ], 365 | "engines": { 366 | "node": ">=12" 367 | } 368 | }, 369 | "node_modules/esbuild": { 370 | "version": "0.18.20", 371 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", 372 | "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", 373 | "dev": true, 374 | "hasInstallScript": true, 375 | "bin": { 376 | "esbuild": "bin/esbuild" 377 | }, 378 | "engines": { 379 | "node": ">=12" 380 | }, 381 | "optionalDependencies": { 382 | "@esbuild/android-arm": "0.18.20", 383 | "@esbuild/android-arm64": "0.18.20", 384 | "@esbuild/android-x64": "0.18.20", 385 | "@esbuild/darwin-arm64": "0.18.20", 386 | "@esbuild/darwin-x64": "0.18.20", 387 | "@esbuild/freebsd-arm64": "0.18.20", 388 | "@esbuild/freebsd-x64": "0.18.20", 389 | "@esbuild/linux-arm": "0.18.20", 390 | "@esbuild/linux-arm64": "0.18.20", 391 | "@esbuild/linux-ia32": "0.18.20", 392 | "@esbuild/linux-loong64": "0.18.20", 393 | "@esbuild/linux-mips64el": "0.18.20", 394 | "@esbuild/linux-ppc64": "0.18.20", 395 | "@esbuild/linux-riscv64": "0.18.20", 396 | "@esbuild/linux-s390x": "0.18.20", 397 | "@esbuild/linux-x64": "0.18.20", 398 | "@esbuild/netbsd-x64": "0.18.20", 399 | "@esbuild/openbsd-x64": "0.18.20", 400 | "@esbuild/sunos-x64": "0.18.20", 401 | "@esbuild/win32-arm64": "0.18.20", 402 | "@esbuild/win32-ia32": "0.18.20", 403 | "@esbuild/win32-x64": "0.18.20" 404 | } 405 | }, 406 | "node_modules/fsevents": { 407 | "version": "2.3.3", 408 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 409 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 410 | "dev": true, 411 | "hasInstallScript": true, 412 | "optional": true, 413 | "os": [ 414 | "darwin" 415 | ], 416 | "engines": { 417 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 418 | } 419 | }, 420 | "node_modules/nanoid": { 421 | "version": "3.3.6", 422 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", 423 | "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", 424 | "dev": true, 425 | "funding": [ 426 | { 427 | "type": "github", 428 | "url": "https://github.com/sponsors/ai" 429 | } 430 | ], 431 | "bin": { 432 | "nanoid": "bin/nanoid.cjs" 433 | }, 434 | "engines": { 435 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 436 | } 437 | }, 438 | "node_modules/picocolors": { 439 | "version": "1.0.0", 440 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 441 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", 442 | "dev": true 443 | }, 444 | "node_modules/postcss": { 445 | "version": "8.4.30", 446 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.30.tgz", 447 | "integrity": "sha512-7ZEao1g4kd68l97aWG/etQKPKq07us0ieSZ2TnFDk11i0ZfDW2AwKHYU8qv4MZKqN2fdBfg+7q0ES06UA73C1g==", 448 | "dev": true, 449 | "funding": [ 450 | { 451 | "type": "opencollective", 452 | "url": "https://opencollective.com/postcss/" 453 | }, 454 | { 455 | "type": "tidelift", 456 | "url": "https://tidelift.com/funding/github/npm/postcss" 457 | }, 458 | { 459 | "type": "github", 460 | "url": "https://github.com/sponsors/ai" 461 | } 462 | ], 463 | "dependencies": { 464 | "nanoid": "^3.3.6", 465 | "picocolors": "^1.0.0", 466 | "source-map-js": "^1.0.2" 467 | }, 468 | "engines": { 469 | "node": "^10 || ^12 || >=14" 470 | } 471 | }, 472 | "node_modules/rollup": { 473 | "version": "3.29.2", 474 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.2.tgz", 475 | "integrity": "sha512-CJouHoZ27v6siztc21eEQGo0kIcE5D1gVPA571ez0mMYb25LGYGKnVNXpEj5MGlepmDWGXNjDB5q7uNiPHC11A==", 476 | "dev": true, 477 | "bin": { 478 | "rollup": "dist/bin/rollup" 479 | }, 480 | "engines": { 481 | "node": ">=14.18.0", 482 | "npm": ">=8.0.0" 483 | }, 484 | "optionalDependencies": { 485 | "fsevents": "~2.3.2" 486 | } 487 | }, 488 | "node_modules/source-map-js": { 489 | "version": "1.0.2", 490 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", 491 | "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", 492 | "dev": true, 493 | "engines": { 494 | "node": ">=0.10.0" 495 | } 496 | }, 497 | "node_modules/three": { 498 | "version": "0.156.1", 499 | "resolved": "https://registry.npmjs.org/three/-/three-0.156.1.tgz", 500 | "integrity": "sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ==" 501 | }, 502 | "node_modules/vite": { 503 | "version": "4.4.9", 504 | "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz", 505 | "integrity": "sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==", 506 | "dev": true, 507 | "dependencies": { 508 | "esbuild": "^0.18.10", 509 | "postcss": "^8.4.27", 510 | "rollup": "^3.27.1" 511 | }, 512 | "bin": { 513 | "vite": "bin/vite.js" 514 | }, 515 | "engines": { 516 | "node": "^14.18.0 || >=16.0.0" 517 | }, 518 | "funding": { 519 | "url": "https://github.com/vitejs/vite?sponsor=1" 520 | }, 521 | "optionalDependencies": { 522 | "fsevents": "~2.3.2" 523 | }, 524 | "peerDependencies": { 525 | "@types/node": ">= 14", 526 | "less": "*", 527 | "lightningcss": "^1.21.0", 528 | "sass": "*", 529 | "stylus": "*", 530 | "sugarss": "*", 531 | "terser": "^5.4.0" 532 | }, 533 | "peerDependenciesMeta": { 534 | "@types/node": { 535 | "optional": true 536 | }, 537 | "less": { 538 | "optional": true 539 | }, 540 | "lightningcss": { 541 | "optional": true 542 | }, 543 | "sass": { 544 | "optional": true 545 | }, 546 | "stylus": { 547 | "optional": true 548 | }, 549 | "sugarss": { 550 | "optional": true 551 | }, 552 | "terser": { 553 | "optional": true 554 | } 555 | } 556 | } 557 | } 558 | } 559 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minecraft-test-2", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host 127.0.0.1", 8 | "build": "vite build", 9 | "preview": "vite preview --host 127.0.0.1" 10 | }, 11 | "devDependencies": { 12 | "vite": "^4.4.5" 13 | }, 14 | "dependencies": { 15 | "three": "^0.156.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /public/fonts/Minecraft.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/fonts/Minecraft.ttf -------------------------------------------------------------------------------- /public/models/pickaxe.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/models/pickaxe.glb -------------------------------------------------------------------------------- /public/textures/cactus_side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/cactus_side.png -------------------------------------------------------------------------------- /public/textures/cactus_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/cactus_top.png -------------------------------------------------------------------------------- /public/textures/coal_ore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/coal_ore.png -------------------------------------------------------------------------------- /public/textures/dirt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/dirt.png -------------------------------------------------------------------------------- /public/textures/grass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/grass.png -------------------------------------------------------------------------------- /public/textures/grass_side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/grass_side.png -------------------------------------------------------------------------------- /public/textures/iron_ore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/iron_ore.png -------------------------------------------------------------------------------- /public/textures/jungle_leaves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/jungle_leaves.png -------------------------------------------------------------------------------- /public/textures/jungle_tree_side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/jungle_tree_side.png -------------------------------------------------------------------------------- /public/textures/jungle_tree_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/jungle_tree_top.png -------------------------------------------------------------------------------- /public/textures/leaves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/leaves.png -------------------------------------------------------------------------------- /public/textures/pickaxe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/pickaxe.png -------------------------------------------------------------------------------- /public/textures/sand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/sand.png -------------------------------------------------------------------------------- /public/textures/snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/snow.png -------------------------------------------------------------------------------- /public/textures/snow_side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/snow_side.png -------------------------------------------------------------------------------- /public/textures/stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/stone.png -------------------------------------------------------------------------------- /public/textures/tree_side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/tree_side.png -------------------------------------------------------------------------------- /public/textures/tree_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/minecraft-threejs-clone/52aabd3306ae7bf35e2df5307bdd98678eaea390/public/textures/tree_top.png -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/blocks.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const textureLoader = new THREE.TextureLoader(); 4 | 5 | function loadTexture(path) { 6 | const texture = textureLoader.load(path); 7 | texture.colorSpace = THREE.SRGBColorSpace; 8 | texture.magFilter = THREE.NearestFilter; 9 | texture.minFilter = THREE.NearestFilter; 10 | return texture; 11 | } 12 | 13 | const textures = { 14 | cactusSide: loadTexture('textures/cactus_side.png'), 15 | cactusTop: loadTexture('textures/cactus_top.png'), 16 | dirt: loadTexture('textures/dirt.png'), 17 | grass: loadTexture('textures/grass.png'), 18 | grassSide: loadTexture('textures/grass_side.png'), 19 | coalOre: loadTexture('textures/coal_ore.png'), 20 | ironOre: loadTexture('textures/iron_ore.png'), 21 | jungleTreeSide: loadTexture('textures/jungle_tree_side.png'), 22 | jungleTreeTop: loadTexture('textures/jungle_tree_top.png'), 23 | jungleLeaves: loadTexture('textures/jungle_leaves.png'), 24 | leaves: loadTexture('textures/leaves.png'), 25 | treeSide: loadTexture('textures/tree_side.png'), 26 | treeTop: loadTexture('textures/tree_top.png'), 27 | sand: loadTexture('textures/sand.png'), 28 | snow: loadTexture('textures/snow.png'), 29 | snowSide: loadTexture('textures/snow_side.png'), 30 | stone: loadTexture('textures/stone.png'), 31 | }; 32 | 33 | export const blocks = { 34 | empty: { 35 | id: 0, 36 | name: 'empty', 37 | visible: false 38 | }, 39 | grass: { 40 | id: 1, 41 | name: 'grass', 42 | material: [ 43 | new THREE.MeshLambertMaterial({ map: textures.grassSide }), // right 44 | new THREE.MeshLambertMaterial({ map: textures.grassSide }), // left 45 | new THREE.MeshLambertMaterial({ map: textures.grass }), // top 46 | new THREE.MeshLambertMaterial({ map: textures.dirt }), // bottom 47 | new THREE.MeshLambertMaterial({ map: textures.grassSide }), // front 48 | new THREE.MeshLambertMaterial({ map: textures.grassSide }) // back 49 | ] 50 | }, 51 | dirt: { 52 | id: 2, 53 | name: 'dirt', 54 | material: new THREE.MeshLambertMaterial({ map: textures.dirt }) 55 | }, 56 | stone: { 57 | id: 3, 58 | name: 'stone', 59 | material: new THREE.MeshLambertMaterial({ map: textures.stone }), 60 | scale: { x: 30, y: 30, z: 30 }, 61 | scarcity: 0.8 62 | }, 63 | coalOre: { 64 | id: 4, 65 | name: 'coal_ore', 66 | material: new THREE.MeshLambertMaterial({ map: textures.coalOre }), 67 | scale: { x: 20, y: 20, z: 20 }, 68 | scarcity: 0.8 69 | }, 70 | ironOre: { 71 | id: 5, 72 | name: 'iron_ore', 73 | material: new THREE.MeshLambertMaterial({ map: textures.ironOre }), 74 | scale: { x: 40, y: 40, z: 40 }, 75 | scarcity: 0.9 76 | }, 77 | tree: { 78 | id: 6, 79 | name: 'tree', 80 | visible: true, 81 | material: [ 82 | new THREE.MeshLambertMaterial({ map: textures.treeSide }), // right 83 | new THREE.MeshLambertMaterial({ map: textures.treeSide }), // left 84 | new THREE.MeshLambertMaterial({ map: textures.treeTop }), // top 85 | new THREE.MeshLambertMaterial({ map: textures.treeTop }), // bottom 86 | new THREE.MeshLambertMaterial({ map: textures.treeSide }), // front 87 | new THREE.MeshLambertMaterial({ map: textures.treeSide }) // back 88 | ] 89 | }, 90 | leaves: { 91 | id: 7, 92 | name: 'leaves', 93 | visible: true, 94 | material: new THREE.MeshLambertMaterial({ map: textures.leaves }) 95 | }, 96 | sand: { 97 | id: 8, 98 | name: 'sand', 99 | visible: true, 100 | material: new THREE.MeshLambertMaterial({ map: textures.sand }) 101 | }, 102 | cloud: { 103 | id: 9, 104 | name: 'cloud', 105 | visible: true, 106 | material: new THREE.MeshBasicMaterial({ color: 0xf0f0f0 }) 107 | }, 108 | snow: { 109 | id: 10, 110 | name: 'snow', 111 | material: [ 112 | new THREE.MeshLambertMaterial({ map: textures.snowSide }), // right 113 | new THREE.MeshLambertMaterial({ map: textures.snowSide }), // left 114 | new THREE.MeshLambertMaterial({ map: textures.snow }), // top 115 | new THREE.MeshLambertMaterial({ map: textures.dirt }), // bottom 116 | new THREE.MeshLambertMaterial({ map: textures.snowSide }), // front 117 | new THREE.MeshLambertMaterial({ map: textures.snowSide }) // back 118 | ] 119 | }, 120 | jungleTree: { 121 | id: 11, 122 | name: 'jungleTree', 123 | material: [ 124 | new THREE.MeshLambertMaterial({ map: textures.jungleTreeSide }), // right 125 | new THREE.MeshLambertMaterial({ map: textures.jungleTreeSide }), // left 126 | new THREE.MeshLambertMaterial({ map: textures.jungleTreeTop }), // top 127 | new THREE.MeshLambertMaterial({ map: textures.jungleTreeTop }), // bottom 128 | new THREE.MeshLambertMaterial({ map: textures.jungleTreeSide }), // front 129 | new THREE.MeshLambertMaterial({ map: textures.jungleTreeSide }) // back 130 | ] 131 | }, 132 | jungleLeaves: { 133 | id: 12, 134 | name: 'jungleLeaves', 135 | material: new THREE.MeshLambertMaterial({ map: textures.jungleLeaves }) 136 | }, 137 | cactus: { 138 | id: 13, 139 | name: 'cactus', 140 | material: [ 141 | new THREE.MeshLambertMaterial({ map: textures.cactusSide }), // right 142 | new THREE.MeshLambertMaterial({ map: textures.cactusSide }), // left 143 | new THREE.MeshLambertMaterial({ map: textures.cactusTop }), // top 144 | new THREE.MeshLambertMaterial({ map: textures.cactusTop }), // bottom 145 | new THREE.MeshLambertMaterial({ map: textures.cactusSide }), // front 146 | new THREE.MeshLambertMaterial({ map: textures.cactusSide }) // back 147 | ] 148 | }, 149 | jungleGrass: { 150 | id: 14, 151 | name: 'jungleGrass', 152 | material: [ 153 | new THREE.MeshLambertMaterial({ color: 0x80c080, map: textures.grassSide }), // right 154 | new THREE.MeshLambertMaterial({ color: 0x80c080, map: textures.grassSide }), // left 155 | new THREE.MeshLambertMaterial({ color: 0x80c080, map: textures.grass }), // top 156 | new THREE.MeshLambertMaterial({ color: 0x80c080, map: textures.dirt }), // bottom 157 | new THREE.MeshLambertMaterial({ color: 0x80c080, map: textures.grassSide }), // front 158 | new THREE.MeshLambertMaterial({ color: 0x80c080, map: textures.grassSide }) // back 159 | ] 160 | }, 161 | }; 162 | 163 | export const resources = [ 164 | blocks.stone, 165 | blocks.coalOre, 166 | blocks.ironOre 167 | ]; -------------------------------------------------------------------------------- /scripts/dataStore.js: -------------------------------------------------------------------------------- 1 | export class DataStore { 2 | constructor() { 3 | this.data = {}; 4 | } 5 | 6 | clear() { 7 | this.data = {}; 8 | } 9 | 10 | contains(chunkX, chunkZ, blockX, blockY, blockZ) { 11 | const key = this.#getKey(chunkX, chunkZ, blockX, blockY, blockZ); 12 | return this.data[key] !== undefined; 13 | } 14 | 15 | get(chunkX, chunkZ, blockX, blockY, blockZ) { 16 | const key = this.#getKey(chunkX, chunkZ, blockX, blockY, blockZ); 17 | const blockId = this.data[key]; 18 | return blockId; 19 | } 20 | 21 | set(chunkX, chunkZ, blockX, blockY, blockZ, blockId) { 22 | const key = this.#getKey(chunkX, chunkZ, blockX, blockY, blockZ); 23 | this.data[key] = blockId; 24 | } 25 | 26 | #getKey(chunkX, chunkZ, blockX, blockY, blockZ) { 27 | return `${chunkX},${chunkZ},${blockX},${blockY},${blockZ}`; 28 | } 29 | } -------------------------------------------------------------------------------- /scripts/main.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import Stats from 'three/examples/jsm/libs/stats.module.js'; 3 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 4 | import { World } from './world'; 5 | import { Player } from './player'; 6 | import { Physics } from './physics'; 7 | import { setupUI } from './ui'; 8 | import { ModelLoader } from './modelLoader'; 9 | 10 | // UI Setup 11 | const stats = new Stats(); 12 | document.body.appendChild(stats.dom); 13 | 14 | // Renderer setup 15 | const renderer = new THREE.WebGLRenderer(); 16 | renderer.setPixelRatio(window.devicePixelRatio); 17 | renderer.setSize(window.innerWidth, window.innerHeight); 18 | renderer.setClearColor(0x80a0e0); 19 | renderer.shadowMap.enabled = true; 20 | renderer.shadowMap.type = THREE.PCFSoftShadowMap; 21 | document.body.appendChild(renderer.domElement); 22 | 23 | // Scene setup 24 | const scene = new THREE.Scene(); 25 | scene.fog = new THREE.Fog(0x80a0e0, 50, 75); 26 | 27 | const world = new World(); 28 | world.generate(); 29 | scene.add(world); 30 | 31 | const player = new Player(scene, world); 32 | const physics = new Physics(scene); 33 | 34 | // Camera setup 35 | const orbitCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 36 | orbitCamera.position.set(24, 24, 24); 37 | orbitCamera.layers.enable(1); 38 | 39 | const controls = new OrbitControls(orbitCamera, renderer.domElement); 40 | controls.update(); 41 | 42 | const modelLoader = new ModelLoader((models) => { 43 | player.setTool(models.pickaxe); 44 | }) 45 | 46 | let sun; 47 | function setupLights() { 48 | sun = new THREE.DirectionalLight(); 49 | sun.intensity = 1.5; 50 | sun.position.set(50, 50, 50); 51 | sun.castShadow = true; 52 | 53 | // Set the size of the sun's shadow box 54 | sun.shadow.camera.left = -40; 55 | sun.shadow.camera.right = 40; 56 | sun.shadow.camera.top = 40; 57 | sun.shadow.camera.bottom = -40; 58 | sun.shadow.camera.near = 0.1; 59 | sun.shadow.camera.far = 200; 60 | sun.shadow.bias = -0.0001; 61 | sun.shadow.mapSize = new THREE.Vector2(2048, 2048); 62 | scene.add(sun); 63 | scene.add(sun.target); 64 | 65 | const ambient = new THREE.AmbientLight(); 66 | ambient.intensity = 0.2; 67 | scene.add(ambient); 68 | } 69 | 70 | // Render loop 71 | let previousTime = performance.now(); 72 | function animate() { 73 | requestAnimationFrame(animate); 74 | 75 | const currentTime = performance.now(); 76 | const dt = (currentTime - previousTime) / 1000; 77 | 78 | // Only update physics when player controls are locked 79 | if (player.controls.isLocked) { 80 | physics.update(dt, player, world); 81 | player.update(world); 82 | world.update(player); 83 | 84 | // Position the sun relative to the player. Need to adjust both the 85 | // position and target of the sun to keep the same sun angle 86 | sun.position.copy(player.camera.position); 87 | sun.position.sub(new THREE.Vector3(-50, -50, -50)); 88 | sun.target.position.copy(player.camera.position); 89 | 90 | // Update positon of the orbit camera to track player 91 | orbitCamera.position.copy(player.position).add(new THREE.Vector3(16, 16, 16)); 92 | controls.target.copy(player.position); 93 | } 94 | 95 | renderer.render(scene, player.controls.isLocked ? player.camera : orbitCamera); 96 | stats.update(); 97 | 98 | previousTime = currentTime; 99 | } 100 | 101 | window.addEventListener('resize', () => { 102 | // Resize camera aspect ratio and renderer size to the new window size 103 | orbitCamera.aspect = window.innerWidth / window.innerHeight; 104 | orbitCamera.updateProjectionMatrix(); 105 | player.camera.aspect = window.innerWidth / window.innerHeight; 106 | player.camera.updateProjectionMatrix(); 107 | 108 | renderer.setSize(window.innerWidth, window.innerHeight); 109 | }); 110 | 111 | setupUI(world, player, physics, scene); 112 | setupLights(); 113 | animate(); -------------------------------------------------------------------------------- /scripts/modelLoader.js: -------------------------------------------------------------------------------- 1 | import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; 2 | 3 | export class ModelLoader { 4 | loader = new GLTFLoader(); 5 | 6 | models = { 7 | pickaxe: undefined 8 | }; 9 | 10 | constructor(onLoad) { 11 | this.loader.load('./models/pickaxe.glb', (model) => { 12 | const mesh = model.scene; 13 | this.models.pickaxe = mesh; 14 | onLoad(this.models); 15 | }); 16 | } 17 | } -------------------------------------------------------------------------------- /scripts/physics.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { blocks } from './blocks'; 3 | import { Player } from './player'; 4 | import { WorldChunk } from './worldChunk'; 5 | 6 | const collisionMaterial = new THREE.MeshBasicMaterial({ 7 | color: 0xff0000, 8 | transparent: true, 9 | opacity: 0.2 10 | }); 11 | const collisionGeometry = new THREE.BoxGeometry(1.001, 1.001, 1.001); 12 | 13 | const contactMaterial = new THREE.MeshBasicMaterial({ wireframe: true, color: 0x00ff00 }); 14 | const contactGeometry = new THREE.SphereGeometry(0.05, 6, 6); 15 | 16 | export class Physics { 17 | // Acceleration due to gravity 18 | gravity = 32; 19 | 20 | // Physic simulation rate 21 | simulationRate = 250; 22 | stepSize = 1 / this.simulationRate; 23 | // Accumulator to keep track of leftover dt 24 | accumulator = 0; 25 | 26 | constructor(scene) { 27 | this.helpers = new THREE.Group(); 28 | this.helpers.visible = false; 29 | scene.add(this.helpers); 30 | } 31 | 32 | /** 33 | * Moves the physics simulation forward in time by 'dt' 34 | * @param {number} dt 35 | * @param {Player} player 36 | * @param {WorldChunk} world 37 | */ 38 | update(dt, player, world) { 39 | this.accumulator += dt; 40 | while (this.accumulator >= this.stepSize) { 41 | player.velocity.y -= this.gravity * this.stepSize; 42 | player.applyInputs(this.stepSize); 43 | this.detectCollisions(player, world); 44 | this.accumulator -= this.stepSize; 45 | } 46 | } 47 | 48 | /** 49 | * Main function for collision detection 50 | */ 51 | detectCollisions(player, world) { 52 | player.onGround = false; 53 | this.helpers.clear(); 54 | 55 | const candidates = this.broadPhase(player, world); 56 | const collisions = this.narrowPhase(candidates, player); 57 | 58 | if (collisions.length > 0) { 59 | this.resolveCollisions(collisions, player); 60 | } 61 | } 62 | 63 | /** 64 | * Performs a rough search against the world to return all 65 | * possible blocks the player may be colliding with 66 | * @returns {{ id: number, instanceId: number }[]} 67 | */ 68 | broadPhase(player, world) { 69 | const candidates = []; 70 | 71 | // Get the block extents of the player 72 | const minX = Math.floor(player.position.x - player.radius); 73 | const maxX = Math.ceil(player.position.x + player.radius); 74 | const minY = Math.floor(player.position.y - player.height); 75 | const maxY = Math.ceil(player.position.y); 76 | const minZ = Math.floor(player.position.z - player.radius); 77 | const maxZ = Math.ceil(player.position.z + player.radius); 78 | 79 | // Loop through all blocks next to the block the center of the player is in 80 | // If they aren't empty, then they are a possible collision candidate 81 | for (let x = minX; x <= maxX; x++) { 82 | for (let y = minY; y <= maxY; y++) { 83 | for (let z = minZ; z <= maxZ; z++) { 84 | const blockId = world.getBlock(x, y, z)?.id; 85 | if (blockId && blockId !== blocks.empty.id) { 86 | const block = { x, y, z }; 87 | candidates.push(block); 88 | this.addCollisionHelper(block); 89 | } 90 | } 91 | } 92 | } 93 | 94 | //console.log(`Broadphase Candidates: ${candidates.length}`); 95 | 96 | return candidates; 97 | } 98 | 99 | /** 100 | * Narrows down the blocks found in the broad-phase to the set 101 | * of blocks the player is actually colliding with 102 | * @param {{ id: number, instanceId: number }[]} candidates 103 | * @returns 104 | */ 105 | narrowPhase(candidates, player) { 106 | const collisions = []; 107 | 108 | for (const block of candidates) { 109 | // Get the point on the block that is closest to the center of the player's bounding cylinder 110 | const closestPoint = { 111 | x: Math.max(block.x - 0.5, Math.min(player.position.x, block.x + 0.5)), 112 | y: Math.max(block.y - 0.5, Math.min(player.position.y - (player.height / 2), block.y + 0.5)), 113 | z: Math.max(block.z - 0.5, Math.min(player.position.z, block.z + 0.5)) 114 | }; 115 | 116 | // Get distance along each axis between closest point and the center 117 | // of the player's bounding cylinder 118 | const dx = closestPoint.x - player.position.x; 119 | const dy = closestPoint.y - (player.position.y - (player.height / 2)); 120 | const dz = closestPoint.z - player.position.z; 121 | 122 | if (this.pointInPlayerBoundingCylinder(closestPoint, player)) { 123 | // Compute the overlap between the point and the player's bounding 124 | // cylinder along the y-axis and in the xz-plane 125 | const overlapY = (player.height / 2) - Math.abs(dy); 126 | const overlapXZ = player.radius - Math.sqrt(dx * dx + dz * dz); 127 | 128 | // Compute the normal of the collision (pointing away from the contact point) 129 | // and the overlap between the point and the player's bounding cylinder 130 | let normal, overlap; 131 | if (overlapY < overlapXZ) { 132 | normal = new THREE.Vector3(0, -Math.sign(dy), 0); 133 | overlap = overlapY; 134 | player.onGround = true; 135 | } else { 136 | normal = new THREE.Vector3(-dx, 0, -dz).normalize(); 137 | overlap = overlapXZ; 138 | } 139 | 140 | collisions.push({ 141 | block, 142 | contactPoint: closestPoint, 143 | normal, 144 | overlap 145 | }); 146 | 147 | this.addContactPointerHelper(closestPoint); 148 | } 149 | } 150 | 151 | //console.log(`Narrowphase Collisions: ${collisions.length}`); 152 | 153 | return collisions; 154 | } 155 | 156 | /** 157 | * Resolves each of the collisions found in the narrow-phase 158 | * @param {*} collisions 159 | * @param {Player} player 160 | */ 161 | resolveCollisions(collisions, player) { 162 | // Resolve the collisions in order of the smallest overlap to the largest 163 | collisions.sort((a, b) => { 164 | return a.overlap < b.overlap; 165 | }); 166 | 167 | for (const collision of collisions) { 168 | // We need to re-check if the contact point is inside the player bounding 169 | // cylinder for each collision since the player position is updated after 170 | // each collision is resolved 171 | if (!this.pointInPlayerBoundingCylinder(collision.contactPoint, player)) continue; 172 | 173 | // Adjust position of player so the block and player are no longer overlapping 174 | let deltaPosition = collision.normal.clone(); 175 | deltaPosition.multiplyScalar(collision.overlap); 176 | player.position.add(deltaPosition); 177 | 178 | // Get the magnitude of the player's velocity along the collision normal 179 | let magnitude = player.worldVelocity.dot(collision.normal); 180 | // Remove that part of the velocity from the player's velocity 181 | let velocityAdjustment = collision.normal.clone().multiplyScalar(magnitude); 182 | 183 | // Apply the velocity to the player 184 | player.applyWorldDeltaVelocity(velocityAdjustment.negate()); 185 | } 186 | } 187 | 188 | /** 189 | * Returns true if the point 'p' is inside the player's bounding cylinder 190 | * @param {{ x: number, y: number, z: number }} p 191 | * @param {Player} player 192 | * @returns {boolean} 193 | */ 194 | pointInPlayerBoundingCylinder(p, player) { 195 | const dx = p.x - player.position.x; 196 | const dy = p.y - (player.position.y - (player.height / 2)); 197 | const dz = p.z - player.position.z; 198 | const r_sq = dx * dx + dz * dz; 199 | 200 | // Check if contact point is inside the player's bounding cylinder 201 | return (Math.abs(dy) < player.height / 2) && (r_sq < player.radius * player.radius); 202 | } 203 | 204 | /** 205 | * Visualizes the block the player is colliding with 206 | * @param {THREE.Object3D} block 207 | */ 208 | addCollisionHelper(block) { 209 | const blockMesh = new THREE.Mesh(collisionGeometry, collisionMaterial); 210 | blockMesh.position.copy(block); 211 | this.helpers.add(blockMesh); 212 | } 213 | 214 | /** 215 | * Visualizes the contact at the point 'p' 216 | * @param {{ x, y, z }} p 217 | */ 218 | addContactPointerHelper(p) { 219 | const contactMesh = new THREE.Mesh(contactGeometry, contactMaterial); 220 | contactMesh.position.copy(p); 221 | this.helpers.add(contactMesh); 222 | } 223 | } -------------------------------------------------------------------------------- /scripts/player.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; 3 | import { World } from './world'; 4 | import { blocks } from './blocks'; 5 | 6 | const CENTER_SCREEN = new THREE.Vector2(); 7 | 8 | export class Player { 9 | height = 1.75; 10 | radius = 0.5; 11 | maxSpeed = 5; 12 | 13 | jumpSpeed = 10; 14 | sprinting = false; 15 | onGround = false; 16 | 17 | input = new THREE.Vector3(); 18 | velocity = new THREE.Vector3(); 19 | #worldVelocity = new THREE.Vector3(); 20 | 21 | camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100); 22 | cameraHelper = new THREE.CameraHelper(this.camera); 23 | controls = new PointerLockControls(this.camera, document.body); 24 | debugCamera = false; 25 | 26 | raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3(), 0, 3); 27 | selectedCoords = null; 28 | activeBlockId = blocks.empty.id; 29 | 30 | tool = { 31 | // Group that will contain the tool mesh 32 | container: new THREE.Group(), 33 | // Whether or not the tool is currently animating 34 | animate: false, 35 | // The time the animation was started 36 | animationStart: 0, 37 | // The rotation speed of the tool 38 | animationSpeed: 0.025, 39 | // Reference to the current animation 40 | animation: null 41 | } 42 | 43 | constructor(scene, world) { 44 | this.world = world; 45 | this.position.set(32, 32, 32); 46 | this.cameraHelper.visible = false; 47 | scene.add(this.camera); 48 | scene.add(this.cameraHelper); 49 | 50 | // Hide/show instructions based on pointer controls locking/unlocking 51 | this.controls.addEventListener('lock', this.onCameraLock.bind(this)); 52 | this.controls.addEventListener('unlock', this.onCameraUnlock.bind(this)); 53 | 54 | // The tool is parented to the camera 55 | this.camera.add(this.tool.container); 56 | 57 | // Set raycaster to use layer 0 so it doesn't interact with water mesh on layer 1 58 | this.raycaster.layers.set(0); 59 | this.camera.layers.enable(1); 60 | 61 | // Wireframe mesh visualizing the player's bounding cylinder 62 | this.boundsHelper = new THREE.Mesh( 63 | new THREE.CylinderGeometry(this.radius, this.radius, this.height, 16), 64 | new THREE.MeshBasicMaterial({ wireframe: true }) 65 | ); 66 | this.boundsHelper.visible = false; 67 | scene.add(this.boundsHelper); 68 | 69 | // Helper used to highlight the currently active block 70 | const selectionMaterial = new THREE.MeshBasicMaterial({ 71 | transparent: true, 72 | opacity: 0.3, 73 | color: 0xffffaa 74 | }); 75 | const selectionGeometry = new THREE.BoxGeometry(1.01, 1.01, 1.01); 76 | this.selectionHelper = new THREE.Mesh(selectionGeometry, selectionMaterial); 77 | scene.add(this.selectionHelper); 78 | 79 | // Add event listeners for keyboard/mouse events 80 | document.addEventListener('keyup', this.onKeyUp.bind(this)); 81 | document.addEventListener('keydown', this.onKeyDown.bind(this)); 82 | document.addEventListener('mousedown', this.onMouseDown.bind(this)); 83 | } 84 | 85 | onCameraLock() { 86 | document.getElementById('overlay').style.visibility = 'hidden'; 87 | } 88 | 89 | onCameraUnlock() { 90 | if (!this.debugCamera) { 91 | document.getElementById('overlay').style.visibility = 'visible'; 92 | } 93 | } 94 | 95 | /** 96 | * Updates the state of the player 97 | * @param {World} world 98 | */ 99 | update(world) { 100 | this.updateBoundsHelper(); 101 | this.updateRaycaster(world); 102 | 103 | if (this.tool.animate) { 104 | this.updateToolAnimation(); 105 | } 106 | } 107 | 108 | /** 109 | * Updates the raycaster used for block selection 110 | * @param {World} world 111 | */ 112 | updateRaycaster(world) { 113 | this.raycaster.setFromCamera(CENTER_SCREEN, this.camera); 114 | const intersections = this.raycaster.intersectObject(world, true); 115 | 116 | if (intersections.length > 0) { 117 | const intersection = intersections[0]; 118 | 119 | // Get the chunk associated with the selected block 120 | const chunk = intersection.object.parent; 121 | 122 | // Get the transformation matrix for the selected block 123 | const blockMatrix = new THREE.Matrix4(); 124 | intersection.object.getMatrixAt(intersection.instanceId, blockMatrix); 125 | 126 | // Set the selected coordinates to the origin of the chunk, 127 | // then apply the transformation matrix of the block to get 128 | // the block coordinates 129 | this.selectedCoords = chunk.position.clone(); 130 | this.selectedCoords.applyMatrix4(blockMatrix); 131 | 132 | if (this.activeBlockId !== blocks.empty.id) { 133 | // If we are adding a block, move it 1 block over in the direction 134 | // of where the ray intersected the cube 135 | this.selectedCoords.add(intersection.normal); 136 | } 137 | 138 | this.selectionHelper.position.copy(this.selectedCoords); 139 | this.selectionHelper.visible = true; 140 | } else { 141 | this.selectedCoords = null; 142 | this.selectionHelper.visible = false; 143 | } 144 | } 145 | 146 | /** 147 | * Updates the state of the player based on the current user inputs 148 | * @param {Number} dt 149 | */ 150 | applyInputs(dt) { 151 | if (this.controls.isLocked === true) { 152 | this.velocity.x = this.input.x * (this.sprinting ? 1.5 : 1); 153 | this.velocity.z = this.input.z * (this.sprinting ? 1.5 : 1); 154 | this.controls.moveRight(this.velocity.x * dt); 155 | this.controls.moveForward(this.velocity.z * dt); 156 | this.position.y += this.velocity.y * dt; 157 | 158 | if (this.position.y < 0) { 159 | this.position.y = 0; 160 | this.velocity.y = 0; 161 | } 162 | } 163 | 164 | document.getElementById('info-player-position').innerHTML = this.toString(); 165 | } 166 | 167 | /** 168 | * Updates the position of the player's bounding cylinder helper 169 | */ 170 | updateBoundsHelper() { 171 | this.boundsHelper.position.copy(this.camera.position); 172 | this.boundsHelper.position.y -= this.height / 2; 173 | } 174 | 175 | /** 176 | * Set the tool object the player is holding 177 | * @param {THREE.Mesh} tool 178 | */ 179 | setTool(tool) { 180 | this.tool.container.clear(); 181 | this.tool.container.add(tool); 182 | this.tool.container.receiveShadow = true; 183 | this.tool.container.castShadow = true; 184 | 185 | this.tool.container.position.set(0.6, -0.3, -0.5); 186 | this.tool.container.scale.set(0.5, 0.5, 0.5); 187 | this.tool.container.rotation.z = Math.PI / 2; 188 | this.tool.container.rotation.y = Math.PI + 0.2; 189 | } 190 | 191 | /** 192 | * Animates the tool rotation 193 | */ 194 | updateToolAnimation() { 195 | if (this.tool.container.children.length > 0) { 196 | const t = this.tool.animationSpeed * (performance.now() - this.tool.animationStart); 197 | this.tool.container.children[0].rotation.y = 0.5 * Math.sin(t); 198 | } 199 | } 200 | 201 | /** 202 | * Returns the current world position of the player 203 | * @returns {THREE.Vector3} 204 | */ 205 | get position() { 206 | return this.camera.position; 207 | } 208 | 209 | /** 210 | * Returns the velocity of the player in world coordinates 211 | * @returns {THREE.Vector3} 212 | */ 213 | get worldVelocity() { 214 | this.#worldVelocity.copy(this.velocity); 215 | this.#worldVelocity.applyEuler(new THREE.Euler(0, this.camera.rotation.y, 0)); 216 | return this.#worldVelocity; 217 | } 218 | 219 | /** 220 | * Applies a change in velocity 'dv' that is specified in the world frame 221 | * @param {THREE.Vector3} dv 222 | */ 223 | applyWorldDeltaVelocity(dv) { 224 | dv.applyEuler(new THREE.Euler(0, -this.camera.rotation.y, 0)); 225 | this.velocity.add(dv); 226 | } 227 | 228 | /** 229 | * Event handler for 'keyup' event 230 | * @param {KeyboardEvent} event 231 | */ 232 | onKeyDown(event) { 233 | if (!this.controls.isLocked) { 234 | this.debugCamera = false; 235 | this.controls.lock(); 236 | } 237 | 238 | switch (event.code) { 239 | case 'Digit0': 240 | case 'Digit1': 241 | case 'Digit2': 242 | case 'Digit3': 243 | case 'Digit4': 244 | case 'Digit5': 245 | case 'Digit6': 246 | case 'Digit7': 247 | case 'Digit8': 248 | // Update the selected toolbar icon 249 | document.getElementById(`toolbar-${this.activeBlockId}`)?.classList.remove('selected'); 250 | document.getElementById(`toolbar-${event.key}`)?.classList.add('selected'); 251 | 252 | this.activeBlockId = Number(event.key); 253 | 254 | // Update the pickaxe visibility 255 | this.tool.container.visible = (this.activeBlockId === 0); 256 | 257 | break; 258 | case 'KeyW': 259 | this.input.z = this.maxSpeed; 260 | break; 261 | case 'KeyA': 262 | this.input.x = -this.maxSpeed; 263 | break; 264 | case 'KeyS': 265 | this.input.z = -this.maxSpeed; 266 | break; 267 | case 'KeyD': 268 | this.input.x = this.maxSpeed; 269 | break; 270 | case 'KeyR': 271 | if (this.repeat) break; 272 | this.position.y = 32; 273 | this.velocity.set(0, 0, 0); 274 | break; 275 | case 'ShiftLeft': 276 | case 'ShiftRight': 277 | this.sprinting = true; 278 | break; 279 | case 'Space': 280 | if (this.onGround) { 281 | this.velocity.y += this.jumpSpeed; 282 | } 283 | break; 284 | case 'F10': 285 | this.debugCamera = true; 286 | this.controls.unlock(); 287 | break; 288 | } 289 | } 290 | 291 | /** 292 | * Event handler for 'keyup' event 293 | * @param {KeyboardEvent} event 294 | */ 295 | onKeyUp(event) { 296 | switch (event.code) { 297 | case 'KeyW': 298 | this.input.z = 0; 299 | break; 300 | case 'KeyA': 301 | this.input.x = 0; 302 | break; 303 | case 'KeyS': 304 | this.input.z = 0; 305 | break; 306 | case 'KeyD': 307 | this.input.x = 0; 308 | break; 309 | case 'ShiftLeft': 310 | case 'ShiftRight': 311 | this.sprinting = false; 312 | break; 313 | } 314 | } 315 | 316 | /** 317 | * Event handler for 'mousedown'' event 318 | * @param {MouseEvent} event 319 | */ 320 | onMouseDown(event) { 321 | if (this.controls.isLocked) { 322 | // Is a block selected? 323 | if (this.selectedCoords) { 324 | // If active block is an empty block, then we are in delete mode 325 | if (this.activeBlockId === blocks.empty.id) { 326 | this.world.removeBlock( 327 | this.selectedCoords.x, 328 | this.selectedCoords.y, 329 | this.selectedCoords.z 330 | ); 331 | } else { 332 | this.world.addBlock( 333 | this.selectedCoords.x, 334 | this.selectedCoords.y, 335 | this.selectedCoords.z, 336 | this.activeBlockId 337 | ); 338 | } 339 | 340 | // If the tool isn't currently animating, trigger the animation 341 | if (!this.tool.animate) { 342 | this.tool.animate = true; 343 | this.tool.animationStart = performance.now(); 344 | 345 | // Clear the existing timeout so it doesn't cancel our new animation 346 | clearTimeout(this.tool.animation); 347 | 348 | // Stop the animation after 1.5 cycles 349 | this.tool.animation = setTimeout(() => { 350 | this.tool.animate = false; 351 | }, 3 * Math.PI / this.tool.animationSpeed); 352 | } 353 | } 354 | } 355 | } 356 | 357 | /** 358 | * Returns player position in a readable string form 359 | * @returns {string} 360 | */ 361 | toString() { 362 | let str = ''; 363 | str += `X: ${this.position.x.toFixed(3)} `; 364 | str += `Y: ${this.position.y.toFixed(3)} `; 365 | str += `Z: ${this.position.z.toFixed(3)}`; 366 | return str; 367 | } 368 | } -------------------------------------------------------------------------------- /scripts/rng.js: -------------------------------------------------------------------------------- 1 | export class RNG { 2 | m_w = 123456789; 3 | m_z = 987654321; 4 | mask = 0xffffffff; 5 | 6 | constructor(seed) { 7 | this.m_w = (123456789 + seed) & this.mask; 8 | this.m_z = (987654321 - seed) & this.mask; 9 | } 10 | 11 | // Returns number between 0 (inclusive) and 1.0 (exclusive), 12 | // just like Math.random(). 13 | random() { 14 | this.m_z = (36969 * (this.m_z & 65535) + (this.m_z >> 16)) & this.mask; 15 | this.m_w = (18000 * (this.m_w & 65535) + (this.m_w >> 16)) & this.mask; 16 | let result = ((this.m_z << 16) + (this.m_w & 65535)) >>> 0; 17 | result /= 4294967296; 18 | return result; 19 | } 20 | } -------------------------------------------------------------------------------- /scripts/ui.js: -------------------------------------------------------------------------------- 1 | import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; 2 | import { resources } from './blocks'; 3 | import { Physics } from './physics'; 4 | 5 | /** 6 | * Sets up the UI controls 7 | * @param {World} world 8 | * @param {Player} player 9 | * @param {Physics} physics 10 | */ 11 | export function setupUI(world, player, physics, scene) { 12 | const gui = new GUI(); 13 | 14 | const playerFolder = gui.addFolder('Player'); 15 | playerFolder.add(player, 'maxSpeed', 1, 20, 0.1).name('Max Speed'); 16 | playerFolder.add(player, 'jumpSpeed', 1, 10, 0.1).name('Jump Speed'); 17 | playerFolder.add(player.boundsHelper, 'visible').name('Show Player Bounds'); 18 | playerFolder.add(player.cameraHelper, 'visible').name('Show Camera Helper'); 19 | 20 | const physicsFolder = gui.addFolder('Physics'); 21 | physicsFolder.add(physics.helpers, 'visible').name('Visualize Collisions'); 22 | physicsFolder.add(physics, 'simulationRate', 10, 1000).name('Sim Rate'); 23 | 24 | const worldFolder = gui.addFolder('World'); 25 | worldFolder.add(world, 'drawDistance', 0, 5, 1).name('Draw Distance'); 26 | worldFolder.add(world, 'asyncLoading').name('Async Loading'); 27 | worldFolder.add(scene.fog, 'near', 1, 200, 1).name('Fog Near'); 28 | worldFolder.add(scene.fog, 'far', 1, 200, 1).name('Fog Far'); 29 | 30 | const terrainFolder = worldFolder.addFolder('Terrain').close(); 31 | terrainFolder.add(world.params, 'seed', 0, 10000, 1).name('Seed'); 32 | terrainFolder.add(world.params.terrain, 'scale', 10, 100).name('Scale'); 33 | terrainFolder.add(world.params.terrain, 'magnitude', 0, 1).name('Magnitude'); 34 | terrainFolder.add(world.params.terrain, 'offset', 0, 32, 1).name('Offset'); 35 | terrainFolder.add(world.params.terrain, 'waterOffset', 0, 32, 1).name('Water Offset'); 36 | 37 | const biomesFolder = gui.addFolder('Biomes'); 38 | biomesFolder.add(world.params.biomes, 'scale', 100, 10000).name('Biome Scale'); 39 | biomesFolder.add(world.params.biomes.variation, 'amplitude', 0, 1).name('Variation Amplitude'); 40 | biomesFolder.add(world.params.biomes.variation, 'scale', 10, 500).name('Variation Scale'); 41 | biomesFolder.add(world.params.biomes, 'tundraToTemperate', 0, 1).name('Tundra -> Temperate'); 42 | biomesFolder.add(world.params.biomes, 'temperateToJungle', 0, 1).name('Temperate -> Jungle'); 43 | biomesFolder.add(world.params.biomes, 'jungleToDesert', 0, 1).name('Jungle -> Desert'); 44 | 45 | const resourcesFolder = worldFolder.addFolder('Resources').close(); 46 | for (const resource of resources) { 47 | const resourceFolder = resourcesFolder.addFolder(resource.name); 48 | resourceFolder.add(resource, 'scarcity', 0, 1).name('Scarcity'); 49 | resourceFolder.add(resource.scale, 'x', 10, 100).name('Scale X'); 50 | resourceFolder.add(resource.scale, 'y', 10, 100).name('Scale Y'); 51 | resourceFolder.add(resource.scale, 'z', 10, 100).name('Scale Z'); 52 | } 53 | 54 | const treesFolder = terrainFolder.addFolder('Trees').close(); 55 | treesFolder.add(world.params.trees, 'frequency', 0, 0.1).name('Frequency'); 56 | treesFolder.add(world.params.trees.trunk, 'minHeight', 0, 10, 1).name('Min Trunk Height'); 57 | treesFolder.add(world.params.trees.trunk, 'maxHeight', 0, 10, 1).name('Max Trunk Height'); 58 | treesFolder.add(world.params.trees.canopy, 'minRadius', 0, 10, 1).name('Min Canopy Size'); 59 | treesFolder.add(world.params.trees.canopy, 'maxRadius', 0, 10, 1).name('Max Canopy Size'); 60 | treesFolder.add(world.params.trees.canopy, 'density', 0, 1).name('Canopy Density'); 61 | 62 | const cloudsFolder = worldFolder.addFolder('Clouds').close(); 63 | cloudsFolder.add(world.params.clouds, 'density', 0, 1).name('Density'); 64 | cloudsFolder.add(world.params.clouds, 'scale', 1, 100, 1).name('Scale'); 65 | 66 | worldFolder.onFinishChange((event) => { 67 | world.generate(true); 68 | }); 69 | 70 | document.addEventListener('keydown', (event) => { 71 | if (event.code === 'KeyU') { 72 | if (gui._hidden) { 73 | gui.show(); 74 | } else { 75 | gui.hide(); 76 | } 77 | } 78 | }) 79 | } -------------------------------------------------------------------------------- /scripts/world.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { WorldChunk } from './worldChunk'; 3 | import { DataStore } from './dataStore'; 4 | 5 | export class World extends THREE.Group { 6 | 7 | /** 8 | * Whether or not we want to load the chunks asynchronously 9 | */ 10 | asyncLoading = true; 11 | 12 | /** 13 | * The number of chunks to render around the player. 14 | * When this is set to 0, the chunk the player is on 15 | * is the only one that is rendered. If it is set to 1, 16 | * the adjacent chunks are rendered; if set to 2, the 17 | * chunks adjacent to those are rendered, and so on. 18 | */ 19 | drawDistance = 3; 20 | 21 | chunkSize = { 22 | width: 32, 23 | height: 32 24 | }; 25 | 26 | params = { 27 | seed: 0, 28 | terrain: { 29 | scale: 100, 30 | magnitude: 8, 31 | offset: 6, 32 | waterOffset: 4 33 | }, 34 | biomes: { 35 | scale: 500, 36 | variation: { 37 | amplitude: 0.2, 38 | scale: 50 39 | }, 40 | tundraToTemperate: 0.25, 41 | temperateToJungle: 0.5, 42 | jungleToDesert: 0.75 43 | }, 44 | trees: { 45 | trunk: { 46 | minHeight: 4, 47 | maxHeight: 7 48 | }, 49 | canopy: { 50 | minRadius: 3, 51 | maxRadius: 3, 52 | density: 0.7 // Vary between 0.0 and 1.0 53 | }, 54 | frequency: 0.005 55 | }, 56 | clouds: { 57 | scale: 30, 58 | density: 0.3 59 | } 60 | }; 61 | 62 | dataStore = new DataStore(); 63 | 64 | constructor(seed = 0) { 65 | super(); 66 | this.seed = seed; 67 | 68 | document.addEventListener('keydown', (ev) => { 69 | switch (ev.code) { 70 | case 'F1': 71 | this.save(); 72 | break; 73 | case 'F2': 74 | this.load(); 75 | break; 76 | } 77 | }); 78 | } 79 | 80 | /** 81 | * Saves the world data to local storage 82 | */ 83 | save() { 84 | localStorage.setItem('minecraft_params', JSON.stringify(this.params)); 85 | localStorage.setItem('minecraft_data', JSON.stringify(this.dataStore.data)); 86 | document.getElementById('status').innerHTML = 'GAME SAVED'; 87 | setTimeout(() => document.getElementById('status').innerHTML = '', 3000); 88 | } 89 | 90 | /** 91 | * Loads the game from disk 92 | */ 93 | load() { 94 | this.params = JSON.parse(localStorage.getItem('minecraft_params')); 95 | this.dataStore.data = JSON.parse(localStorage.getItem('minecraft_data')); 96 | document.getElementById('status').innerHTML = 'GAME LOADED'; 97 | setTimeout(() => document.getElementById('status').innerHTML = '', 3000); 98 | this.generate(); 99 | } 100 | 101 | /** 102 | * Regenerate the world data model and the meshes 103 | */ 104 | generate(clearCache = false) { 105 | if (clearCache) { 106 | this.dataStore.clear(); 107 | } 108 | 109 | this.disposeChunks(); 110 | 111 | for (let x = -this.drawDistance; x <= this.drawDistance; x++) { 112 | for (let z = -this.drawDistance; z <= this.drawDistance; z++) { 113 | this.generateChunk(x, z); 114 | } 115 | } 116 | } 117 | 118 | /** 119 | * Updates the visible portions of the world based on the 120 | * current player position 121 | * @param {Player} player 122 | */ 123 | update(player) { 124 | const visibleChunks = this.getVisibleChunks(player); 125 | const chunksToAdd = this.getChunksToAdd(visibleChunks); 126 | this.removeUnusedChunks(visibleChunks); 127 | 128 | for (const chunk of chunksToAdd) { 129 | this.generateChunk(chunk.x, chunk.z); 130 | } 131 | } 132 | 133 | /** 134 | * Returns an array containing the coordinates of the chunks that 135 | * are currently visible to the player 136 | * @param {Player} player 137 | * @returns {{ x: number, z: number}[]} 138 | */ 139 | getVisibleChunks(player) { 140 | const visibleChunks = []; 141 | 142 | const coords = this.worldToChunkCoords( 143 | player.position.x, 144 | player.position.y, 145 | player.position.z 146 | ); 147 | 148 | const chunkX = coords.chunk.x; 149 | const chunkZ = coords.chunk.z; 150 | 151 | for (let x = chunkX - this.drawDistance; x <= chunkX + this.drawDistance; x++) { 152 | for (let z = chunkZ - this.drawDistance; z <= chunkZ + this.drawDistance; z++) { 153 | visibleChunks.push({ x, z }); 154 | } 155 | } 156 | 157 | return visibleChunks; 158 | } 159 | 160 | /** 161 | * Returns an array containing the coordinates of the chunks that 162 | * are not yet loaded and need to be added to the scene 163 | * @param {{ x: number, z: number}[]} visibleChunks 164 | * @returns {{ x: number, z: number}[]} 165 | */ 166 | getChunksToAdd(visibleChunks) { 167 | // Filter down the visible chunks to those not already in the world 168 | return visibleChunks.filter((chunk) => { 169 | const chunkExists = this.children 170 | .map((obj) => obj.userData) 171 | .find(({ x, z }) => ( 172 | chunk.x === x && chunk.z === z 173 | )); 174 | 175 | return !chunkExists; 176 | }) 177 | } 178 | 179 | /** 180 | * Removes current loaded chunks that are no longer visible to the player 181 | * @param {{ x: number, z: number}[]} visibleChunks 182 | */ 183 | removeUnusedChunks(visibleChunks) { 184 | // Filter down the visible chunks to those not already in the world 185 | const chunksToRemove = this.children.filter((chunk) => { 186 | const { x, z } = chunk.userData; 187 | const chunkExists = visibleChunks 188 | .find((visibleChunk) => ( 189 | visibleChunk.x === x && visibleChunk.z === z 190 | )); 191 | 192 | return !chunkExists; 193 | }); 194 | 195 | for (const chunk of chunksToRemove) { 196 | chunk.disposeInstances(); 197 | this.remove(chunk); 198 | console.log(`Removing chunk at X: ${chunk.userData.x} Z: ${chunk.userData.z}`); 199 | } 200 | } 201 | 202 | /** 203 | * Generates the chunk at the (x, z) coordinates 204 | * @param {number} x 205 | * @param {number} z 206 | */ 207 | generateChunk(x, z) { 208 | const chunk = new WorldChunk(this.chunkSize, this.params, this.dataStore); 209 | chunk.position.set( 210 | x * this.chunkSize.width, 211 | 0, 212 | z * this.chunkSize.width); 213 | chunk.userData = { x, z }; 214 | 215 | if (this.asyncLoading) { 216 | requestIdleCallback(chunk.generate.bind(chunk), { timeout: 1000 }); 217 | } else { 218 | chunk.generate(); 219 | } 220 | 221 | this.add(chunk); 222 | console.log(`Adding chunk at X: ${x} Z: ${z}`); 223 | } 224 | 225 | /** 226 | * Gets the block data at (x, y, z) 227 | * @param {number} x 228 | * @param {number} y 229 | * @param {number} z 230 | * @returns {{id: number, instanceId: number} | null} 231 | */ 232 | getBlock(x, y, z) { 233 | const coords = this.worldToChunkCoords(x, y, z); 234 | const chunk = this.getChunk(coords.chunk.x, coords.chunk.z); 235 | 236 | if (chunk && chunk.loaded) { 237 | return chunk.getBlock( 238 | coords.block.x, 239 | coords.block.y, 240 | coords.block.z 241 | ); 242 | } else { 243 | return null; 244 | } 245 | } 246 | 247 | /** 248 | * Returns the coordinates of the block at world (x,y,z) 249 | * - `chunk` is the coordinates of the chunk containing the block 250 | * - `block` is the coordinates of the block relative to the chunk 251 | * @param {number} x 252 | * @param {number} y 253 | * @param {number} z 254 | * @returns {{ 255 | * chunk: { x: number, z: number}, 256 | * block: { x: number, y: number, z: number} 257 | * }} 258 | */ 259 | worldToChunkCoords(x, y, z) { 260 | const chunkCoords = { 261 | x: Math.floor(x / this.chunkSize.width), 262 | z: Math.floor(z / this.chunkSize.width) 263 | }; 264 | 265 | const blockCoords = { 266 | x: x - this.chunkSize.width * chunkCoords.x, 267 | y, 268 | z: z - this.chunkSize.width * chunkCoords.z 269 | }; 270 | 271 | return { 272 | chunk: chunkCoords, 273 | block: blockCoords 274 | } 275 | } 276 | 277 | /** 278 | * Returns the WorldChunk object at the specified coordinates 279 | * @param {number} chunkX 280 | * @param {number} chunkZ 281 | * @returns {WorldChunk | null} 282 | */ 283 | getChunk(chunkX, chunkZ) { 284 | return this.children.find((chunk) => ( 285 | chunk.userData.x === chunkX && 286 | chunk.userData.z === chunkZ 287 | )); 288 | } 289 | 290 | disposeChunks() { 291 | this.traverse((chunk) => { 292 | if (chunk.disposeInstances) { 293 | chunk.disposeInstances(); 294 | } 295 | }); 296 | this.clear(); 297 | } 298 | 299 | /** 300 | * Adds a new block at (x,y,z) of type `blockId` 301 | * @param {number} x 302 | * @param {number} y 303 | * @param {number} z 304 | * @param {number} blockId 305 | */ 306 | addBlock(x, y, z, blockId) { 307 | const coords = this.worldToChunkCoords(x, y, z); 308 | const chunk = this.getChunk(coords.chunk.x, coords.chunk.z); 309 | 310 | if (chunk) { 311 | chunk.addBlock( 312 | coords.block.x, 313 | coords.block.y, 314 | coords.block.z, 315 | blockId 316 | ); 317 | 318 | // Hide neighboring blocks if they are completely obscured 319 | this.hideBlock(x - 1, y, z); 320 | this.hideBlock(x + 1, y, z); 321 | this.hideBlock(x, y - 1, z); 322 | this.hideBlock(x, y + 1, z); 323 | this.hideBlock(x, y, z - 1); 324 | this.hideBlock(x, y, z + 1); 325 | } 326 | } 327 | 328 | /** 329 | * Removes the block at (x, y, z) and sets it to empty 330 | * @param {number} x 331 | * @param {number} y 332 | * @param {number} z 333 | */ 334 | removeBlock(x, y, z) { 335 | const coords = this.worldToChunkCoords(x, y, z); 336 | const chunk = this.getChunk(coords.chunk.x, coords.chunk.z); 337 | 338 | // Don't allow removing the first layer of blocks 339 | if (coords.block.y === 0) return; 340 | 341 | if (chunk) { 342 | chunk.removeBlock( 343 | coords.block.x, 344 | coords.block.y, 345 | coords.block.z 346 | ); 347 | 348 | // Reveal adjacent neighbors if they are hidden 349 | this.revealBlock(x - 1, y, z); 350 | this.revealBlock(x + 1, y, z); 351 | this.revealBlock(x, y - 1, z); 352 | this.revealBlock(x, y + 1, z); 353 | this.revealBlock(x, y, z - 1); 354 | this.revealBlock(x, y, z + 1); 355 | } 356 | } 357 | 358 | /** 359 | * Reveals the block at (x,y,z) by adding a new mesh instance 360 | * @param {number} x 361 | * @param {number} y 362 | * @param {number} z 363 | */ 364 | revealBlock(x, y, z) { 365 | const coords = this.worldToChunkCoords(x, y, z); 366 | const chunk = this.getChunk(coords.chunk.x, coords.chunk.z); 367 | 368 | if (chunk) { 369 | chunk.addBlockInstance( 370 | coords.block.x, 371 | coords.block.y, 372 | coords.block.z 373 | ) 374 | } 375 | } 376 | 377 | /** 378 | * Hides the block at (x,y,z) by removing the mesh instance 379 | * @param {number} x 380 | * @param {number} y 381 | * @param {number} z 382 | */ 383 | hideBlock(x, y, z) { 384 | const coords = this.worldToChunkCoords(x, y, z); 385 | const chunk = this.getChunk(coords.chunk.x, coords.chunk.z); 386 | 387 | if (chunk && chunk.isBlockObscured(coords.block.x, coords.block.y, coords.block.z)) { 388 | chunk.deleteBlockInstance( 389 | coords.block.x, 390 | coords.block.y, 391 | coords.block.z 392 | ) 393 | } 394 | } 395 | } -------------------------------------------------------------------------------- /scripts/worldChunk.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { SimplexNoise } from 'three/examples/jsm/math/SimplexNoise.js'; 3 | import { RNG } from './rng'; 4 | import { blocks, resources } from './blocks'; 5 | 6 | const geometry = new THREE.BoxGeometry(); 7 | 8 | export class WorldChunk extends THREE.Group { 9 | /** 10 | * @type {{ 11 | * id: number, 12 | * instanceId: number 13 | * }[][][]} 14 | */ 15 | data = []; 16 | 17 | constructor(size, params, dataStore) { 18 | super(); 19 | this.loaded = false; 20 | this.size = size; 21 | this.params = params; 22 | this.dataStore = dataStore; 23 | } 24 | 25 | /** 26 | * Generates the world data and meshes 27 | */ 28 | generate() { 29 | const start = performance.now(); 30 | 31 | const rng = new RNG(this.params.seed); 32 | this.initializeTerrain(); 33 | this.generateTerrain(rng); 34 | this.generateClouds(rng); 35 | this.loadPlayerChanges(); 36 | this.generateMeshes(); 37 | 38 | this.loaded = true; 39 | 40 | //console.log(`Loaded chunk in ${performance.now() - start}ms`); 41 | } 42 | 43 | /** 44 | * Initializes an empty world 45 | */ 46 | initializeTerrain() { 47 | this.data = []; 48 | for (let x = 0; x < this.size.width; x++) { 49 | const slice = []; 50 | for (let y = 0; y < this.size.height; y++) { 51 | const row = []; 52 | for (let z = 0; z < this.size.width; z++) { 53 | row.push({ 54 | id: blocks.empty.id, 55 | instanceId: null 56 | }); 57 | } 58 | slice.push(row); 59 | } 60 | this.data.push(slice); 61 | } 62 | } 63 | 64 | /** 65 | * Get the biome at the local chunk coordinates (x,z) 66 | * @param {SimplexNoise} simplex 67 | * @param {number} x 68 | * @param {number} z 69 | */ 70 | getBiome(simplex, x, z) { 71 | let noise = 0.5 * simplex.noise( 72 | (this.position.x + x) / this.params.biomes.scale, 73 | (this.position.z + z) / this.params.biomes.scale 74 | ) + 0.5; 75 | 76 | noise += this.params.biomes.variation.amplitude * (simplex.noise( 77 | (this.position.x + x) / this.params.biomes.variation.scale, 78 | (this.position.z + z) / this.params.biomes.variation.scale 79 | )); 80 | 81 | if (noise < this.params.biomes.tundraToTemperate) { 82 | return 'Tundra'; 83 | } else if (noise < this.params.biomes.temperateToJungle) { 84 | return 'Temperate'; 85 | } else if (noise < this.params.biomes.jungleToDesert) { 86 | return 'Jungle'; 87 | } else { 88 | return 'Desert'; 89 | } 90 | } 91 | 92 | /** 93 | * Generates the terrain data for the world 94 | */ 95 | generateTerrain(rng) { 96 | const simplex = new SimplexNoise(rng); 97 | for (let x = 0; x < this.size.width; x++) { 98 | for (let z = 0; z < this.size.width; z++) { 99 | const biome = this.getBiome(simplex, x, z); 100 | 101 | // Compute the noise value at this x-z location 102 | const value = simplex.noise( 103 | (this.position.x + x) / this.params.terrain.scale, 104 | (this.position.z + z) / this.params.terrain.scale 105 | ); 106 | 107 | // Scale the noise based on the magnitude/offset 108 | const scaledNoise = this.params.terrain.offset + 109 | this.params.terrain.magnitude * value; 110 | 111 | // Computing the height of the terrain at this x-z location 112 | let height = Math.floor(scaledNoise); 113 | 114 | // Clamping height between 0 and max height 115 | height = Math.max(0, Math.min(height, this.size.height - 1)); 116 | 117 | // Fill in all blocks at or below the terrain height 118 | for (let y = this.size.height; y >= 0; y--) { 119 | if (y <= this.params.terrain.waterOffset && y === height) { 120 | this.setBlockId(x, y, z, blocks.sand.id); 121 | } else if (y === height) { 122 | let groundBlockType; 123 | if (biome === 'Desert') { 124 | groundBlockType = blocks.sand.id; 125 | } else if (biome === 'Temperate' || biome === 'Jungle') { 126 | groundBlockType = blocks.grass.id; 127 | } else if (biome === 'Tundra') { 128 | groundBlockType = blocks.snow.id; 129 | } else if (biome === 'Jungle') { 130 | groundBlockType = blocks.jungleGrass.id; 131 | } 132 | 133 | this.setBlockId(x, y, z, groundBlockType); 134 | 135 | // Randomly generate a tree 136 | if (rng.random() < this.params.trees.frequency) { 137 | this.generateTree(rng, biome, x, height + 1, z); 138 | } 139 | } else if (y < height && this.getBlock(x, y, z).id === blocks.empty.id) { 140 | this.generateResourceIfNeeded(simplex, x, y, z); 141 | } 142 | } 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * Determines if a resource block should be generated at (x, y, z) 149 | * @param {SimplexNoise} simplex 150 | * @param {number} x 151 | * @param {number} y 152 | * @param {number} z 153 | */ 154 | generateResourceIfNeeded(simplex, x, y, z) { 155 | this.setBlockId(x, y, z, blocks.dirt.id); 156 | resources.forEach(resource => { 157 | const value = simplex.noise3d( 158 | (this.position.x + x) / resource.scale.x, 159 | (this.position.y + y) / resource.scale.y, 160 | (this.position.z + z) / resource.scale.z); 161 | 162 | if (value > resource.scarcity) { 163 | this.setBlockId(x, y, z, resource.id); 164 | } 165 | }); 166 | } 167 | 168 | /** 169 | * Creates a tree appropriate for the biome at (x, y, z) 170 | * @param {string} biome 171 | * @param {number} x 172 | * @param {number} y 173 | * @param {number} z 174 | */ 175 | generateTree(rng, biome, x, y, z) { 176 | const minH = this.params.trees.trunk.minHeight; 177 | const maxH = this.params.trees.trunk.maxHeight; 178 | const h = Math.round(minH + (maxH - minH) * rng.random()); 179 | 180 | for (let treeY = y; treeY < y + h; treeY++) { 181 | if (biome === 'Temperate' || biome === 'Tundra') { 182 | this.setBlockId(x, treeY, z, blocks.tree.id); 183 | } else if (biome === 'Jungle') { 184 | this.setBlockId(x, treeY, z, blocks.jungleTree.id); 185 | } else if (biome === 'Desert') { 186 | this.setBlockId(x, treeY, z, blocks.cactus.id); 187 | } 188 | } 189 | 190 | // Generate canopy centered on the top of the tree 191 | if (biome === 'Temperate' || biome === 'Jungle') { 192 | this.generateTreeCanopy(biome, x, y + h, z, rng); 193 | } 194 | } 195 | 196 | generateTreeCanopy(biome, centerX, centerY, centerZ, rng) { 197 | const minR = this.params.trees.canopy.minRadius; 198 | const maxR = this.params.trees.canopy.maxRadius; 199 | const r = Math.round(minR + (maxR - minR) * rng.random()); 200 | 201 | for (let x = -r; x <= r; x++) { 202 | for (let y = -r; y <= r; y++) { 203 | for (let z = -r; z <= r; z++) { 204 | const n = rng.random(); 205 | 206 | // Make sure the block is within the canopy radius 207 | if (x * x + y * y + z * z > r * r) continue; 208 | // Don't overwrite an existing block 209 | const block = this.getBlock(centerX + x, centerY + y, centerZ + z); 210 | if (block && block.id !== blocks.empty.id) continue; 211 | // Fill in the tree canopy with leaves based on the density parameter 212 | if (n < this.params.trees.canopy.density) { 213 | if (biome === 'Temperate') { 214 | this.setBlockId(centerX + x, centerY + y, centerZ + z, blocks.leaves.id); 215 | } else if (biome === 'Jungle') { 216 | this.setBlockId(centerX + x, centerY + y, centerZ + z, blocks.jungleLeaves.id); 217 | } 218 | } 219 | } 220 | } 221 | } 222 | } 223 | 224 | /** 225 | * Creates happy little clouds 226 | * @param {RNG} rng 227 | */ 228 | generateClouds(rng) { 229 | const simplex = new SimplexNoise(rng); 230 | for (let x = 0; x < this.size.width; x++) { 231 | for (let z = 0; z < this.size.width; z++) { 232 | const value = (simplex.noise( 233 | (this.position.x + x) / this.params.clouds.scale, 234 | (this.position.z + z) / this.params.clouds.scale 235 | ) + 1) * 0.5; 236 | 237 | if (value < this.params.clouds.density) { 238 | this.setBlockId(x, this.size.height - 1, z, blocks.cloud.id); 239 | } 240 | } 241 | } 242 | } 243 | 244 | /** 245 | * Pulls any changes from the data store and applies them to the data model 246 | */ 247 | loadPlayerChanges() { 248 | for (let x = 0; x < this.size.width; x++) { 249 | for (let y = 0; y < this.size.height; y++) { 250 | for (let z = 0; z < this.size.width; z++) { 251 | if (this.dataStore.contains(this.position.x, this.position.z, x, y, z)) { 252 | const blockId = this.dataStore.get(this.position.x, this.position.z, x, y, z); 253 | this.setBlockId(x, y, z, blockId); 254 | } 255 | } 256 | } 257 | } 258 | } 259 | 260 | generateWater() { 261 | const material = new THREE.MeshLambertMaterial({ 262 | color: 0x9090e0, 263 | transparent: true, 264 | opacity: 0.5, 265 | side: THREE.DoubleSide 266 | }); 267 | 268 | const waterMesh = new THREE.Mesh(new THREE.PlaneGeometry(), material); 269 | waterMesh.rotateX(-Math.PI / 2.0); 270 | waterMesh.position.set( 271 | this.size.width / 2, 272 | this.params.terrain.waterOffset + 0.4, 273 | this.size.width / 2 274 | ); 275 | waterMesh.scale.set(this.size.width, this.size.width, 1); 276 | waterMesh.layers.set(1); 277 | 278 | this.add(waterMesh); 279 | } 280 | 281 | /** 282 | * Generates the 3D representation of the world from the world data 283 | */ 284 | generateMeshes() { 285 | this.clear(); 286 | 287 | this.generateWater(); 288 | 289 | const maxCount = this.size.width * this.size.width * this.size.height; 290 | 291 | // Creating a lookup table where the key is the block id 292 | const meshes = {}; 293 | Object.values(blocks) 294 | .filter(blockType => blockType.id !== blocks.empty.id) 295 | .forEach(blockType => { 296 | const mesh = new THREE.InstancedMesh(geometry, blockType.material, maxCount); 297 | mesh.name = blockType.id; 298 | mesh.count = 0; 299 | mesh.castShadow = true; 300 | mesh.receiveShadow = true; 301 | meshes[blockType.id] = mesh; 302 | }); 303 | 304 | const matrix = new THREE.Matrix4(); 305 | for (let x = 0; x < this.size.width; x++) { 306 | for (let y = 0; y < this.size.height; y++) { 307 | for (let z = 0; z < this.size.width; z++) { 308 | const blockId = this.getBlock(x, y, z).id; 309 | 310 | if (blockId === blocks.empty.id) continue; 311 | 312 | const mesh = meshes[blockId]; 313 | const instanceId = mesh.count; 314 | 315 | if (!this.isBlockObscured(x, y, z)) { 316 | matrix.setPosition(x, y, z); 317 | mesh.setMatrixAt(instanceId, matrix); 318 | this.setBlockInstanceId(x, y, z, instanceId); 319 | mesh.count++; 320 | } 321 | } 322 | } 323 | } 324 | 325 | this.add(...Object.values(meshes)); 326 | } 327 | 328 | /** 329 | * Gets the block data at (x, y, z) 330 | * @param {number} x 331 | * @param {number} y 332 | * @param {number} z 333 | * @returns {{id: number, instanceId: number}} 334 | */ 335 | getBlock(x, y, z) { 336 | if (this.inBounds(x, y, z)) { 337 | return this.data[x][y][z]; 338 | } else { 339 | return null; 340 | } 341 | } 342 | 343 | /** 344 | * Adds a new block at (x,y,z) of type `blockId` 345 | * @param {number} x 346 | * @param {number} y 347 | * @param {number} z 348 | * @param {number} blockId 349 | */ 350 | addBlock(x, y, z, blockId) { 351 | if (this.getBlock(x, y, z).id === blocks.empty.id) { 352 | this.setBlockId(x, y, z, blockId); 353 | this.addBlockInstance(x, y, z); 354 | this.dataStore.set(this.position.x, this.position.z, x, y, z, blockId); 355 | } 356 | } 357 | 358 | /** 359 | * Removes the block at (x, y, z) 360 | * @param {number} x 361 | * @param {number} y 362 | * @param {number} z 363 | */ 364 | removeBlock(x, y, z) { 365 | const block = this.getBlock(x, y, z); 366 | if (block && block.id !== blocks.empty.id) { 367 | this.deleteBlockInstance(x, y, z); 368 | this.setBlockId(x, y, z, blocks.empty.id); 369 | this.dataStore.set(this.position.x, this.position.z, x, y, z, blocks.empty.id); 370 | } 371 | } 372 | 373 | /** 374 | * Removes the mesh instance associated with `block` by swapping it 375 | * with the last instance and decrementing the instance count. 376 | * @param {number} x 377 | * @param {number} y 378 | * @param {number} z 379 | */ 380 | deleteBlockInstance(x, y, z) { 381 | const block = this.getBlock(x, y, z); 382 | 383 | if (block.id === blocks.empty.id || block.instanceId === null) return; 384 | 385 | // Get the mesh and instance id of the block 386 | const mesh = this.children.find((instanceMesh) => instanceMesh.name === block.id); 387 | const instanceId = block.instanceId; 388 | 389 | // Swapping the transformation matrix of the block in the last position 390 | // with the block that we are going to remove 391 | const lastMatrix = new THREE.Matrix4(); 392 | mesh.getMatrixAt(mesh.count - 1, lastMatrix); 393 | 394 | // Updating the instance id of the block in the last position to its new instance id 395 | const v = new THREE.Vector3(); 396 | v.applyMatrix4(lastMatrix); 397 | this.setBlockInstanceId(v.x, v.y, v.z, instanceId); 398 | 399 | // Swapping the transformation matrices 400 | mesh.setMatrixAt(instanceId, lastMatrix); 401 | 402 | // This effectively removes the last instance from the scene 403 | mesh.count--; 404 | 405 | // Notify the instanced mesh we updated the instance matrix 406 | // Also re-compute the bounding sphere so raycasting works 407 | mesh.instanceMatrix.needsUpdate = true; 408 | mesh.computeBoundingSphere(); 409 | 410 | // Remove the instance associated with the block and update the data model 411 | this.setBlockInstanceId(x, y, z, null); 412 | } 413 | 414 | /** 415 | * Create a new instance for the block at (x,y,z) 416 | * @param {number} x 417 | * @param {number} y 418 | * @param {number} z 419 | */ 420 | addBlockInstance(x, y, z) { 421 | const block = this.getBlock(x, y, z); 422 | 423 | // Verify the block exists, it isn't an empty block type, and it doesn't already have an instance 424 | if (block && block.id !== blocks.empty.id && block.instanceId === null) { 425 | // Get the mesh and instance id of the block 426 | const mesh = this.children.find((instanceMesh) => instanceMesh.name === block.id); 427 | const instanceId = mesh.count++; 428 | this.setBlockInstanceId(x, y, z, instanceId); 429 | 430 | // Compute the transformation matrix for the new instance and update the instanced 431 | const matrix = new THREE.Matrix4(); 432 | matrix.setPosition(x, y, z); 433 | mesh.setMatrixAt(instanceId, matrix); 434 | mesh.instanceMatrix.needsUpdate = true; 435 | mesh.computeBoundingSphere(); 436 | } 437 | } 438 | 439 | /** 440 | * Sets the block id for the block at (x, y, z) 441 | * @param {number} x 442 | * @param {number} y 443 | * @param {number} z 444 | * @param {number} id 445 | */ 446 | setBlockId(x, y, z, id) { 447 | if (this.inBounds(x, y, z)) { 448 | this.data[x][y][z].id = id; 449 | } 450 | } 451 | 452 | 453 | /** 454 | * Sets the block instance id for the block at (x, y, z) 455 | * @param {number} x 456 | * @param {number} y 457 | * @param {number} z 458 | * @param {number} instanceId 459 | */ 460 | setBlockInstanceId(x, y, z, instanceId) { 461 | if (this.inBounds(x, y, z)) { 462 | this.data[x][y][z].instanceId = instanceId; 463 | } 464 | } 465 | 466 | /** 467 | * Checks if the (x, y, z) coordinates are within bounds 468 | * @param {number} x 469 | * @param {number} y 470 | * @param {number} z 471 | * @returns {boolean} 472 | */ 473 | inBounds(x, y, z) { 474 | if (x >= 0 && x < this.size.width && 475 | y >= 0 && y < this.size.height && 476 | z >= 0 && z < this.size.width) { 477 | return true; 478 | } else { 479 | return false; 480 | } 481 | } 482 | 483 | /** 484 | * Returns true if this block is completely hidden by other blocks 485 | * @param {number} x 486 | * @param {number} y 487 | * @param {number} z 488 | * @returns {boolean} 489 | */ 490 | isBlockObscured(x, y, z) { 491 | const up = this.getBlock(x, y + 1, z)?.id ?? blocks.empty.id; 492 | const down = this.getBlock(x, y - 1, z)?.id ?? blocks.empty.id; 493 | const left = this.getBlock(x + 1, y, z)?.id ?? blocks.empty.id; 494 | const right = this.getBlock(x - 1, y, z)?.id ?? blocks.empty.id; 495 | const forward = this.getBlock(x, y, z + 1)?.id ?? blocks.empty.id; 496 | const back = this.getBlock(x, y, z - 1)?.id ?? blocks.empty.id; 497 | 498 | // If any of the block's sides is exposed, it is not obscured 499 | if (up === blocks.empty.id || 500 | down === blocks.empty.id || 501 | left === blocks.empty.id || 502 | right === blocks.empty.id || 503 | forward === blocks.empty.id || 504 | back === blocks.empty.id) { 505 | return false; 506 | } else { 507 | return true; 508 | } 509 | } 510 | 511 | disposeInstances() { 512 | this.traverse((obj) => { 513 | if (obj.dispose) obj.dispose(); 514 | }); 515 | this.clear(); 516 | } 517 | } -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Minecraft'; 3 | src: url('/fonts/Minecraft.ttf') format('truetype'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | /* remove white border from page */ 9 | body { 10 | margin: 0; 11 | font-family: 'Minecraft'; 12 | } 13 | 14 | h1 { 15 | font-size: 3em; 16 | } 17 | 18 | #info { 19 | position: absolute; 20 | font-family: sans-serif; 21 | font-size: 24px; 22 | right: 0; 23 | bottom: 0; 24 | color: white; 25 | margin: 8px; 26 | } 27 | 28 | #toolbar-container { 29 | position: fixed; 30 | bottom: 8px; 31 | width: 100%; 32 | 33 | display: flex; 34 | justify-content: center; 35 | } 36 | 37 | #toolbar { 38 | background-color: rgb(109, 109, 109); 39 | border: 4px solid rgb(147, 147, 147); 40 | padding: 8px; 41 | display: flex; 42 | justify-content: space-between; 43 | column-gap: 12px; 44 | } 45 | 46 | .toolbar-icon { 47 | width: 64px; 48 | height: 64px; 49 | outline: 4px solid rgb(58, 58, 58);; 50 | } 51 | 52 | .toolbar-icon.selected { 53 | outline: 4px solid white; 54 | } 55 | 56 | #overlay { 57 | position: fixed; 58 | top: 0; 59 | left: 0; 60 | bottom: 0; 61 | right: 0; 62 | background-color: #00000080; 63 | 64 | display: flex; 65 | justify-content: center; 66 | align-items: center; 67 | 68 | font-size: 2em; 69 | color: white; 70 | text-align: center; 71 | } 72 | 73 | #status { 74 | position: fixed; 75 | bottom: 8px; 76 | left: 8px; 77 | font-size: 2em; 78 | color: white; 79 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('vite').UserConfig} 3 | */ 4 | export default { 5 | base: '/minecraft-threejs-clone/', 6 | build: { 7 | sourcemap: true 8 | } 9 | } --------------------------------------------------------------------------------