├── .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 | 
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 |
17 |
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 | }
--------------------------------------------------------------------------------