diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..ca74a57d3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,36 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "viewer-server", + "command": "live-server --port=9090", + "type": "shell", + "args": [], + "problemMatcher": [], + // set cwd + "options": { + "cwd": "${workspaceFolder}/prismarine-viewer/public/" + }, + "presentation": { + "reveal": "silent" + }, + }, + { + "label": "viewer-esbuild", + "type": "shell", + "command": "node prismarine-viewer/esbuild.mjs -w", + "problemMatcher": "$esbuild-watch", + "presentation": { + "reveal": "silent" + }, + }, + { + "label": "viewer server+esbuild", + "dependsOn": [ + "viewer-server", + "viewer-esbuild" + ], + "dependsOrder": "parallel", + } + ] +} diff --git a/config.json b/config.json index cbecdc729..123d8f3fc 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,6 @@ { "defaultHost": "pjs.deptofcraft.com", "defaultProxy": "zardoy.site:2344", - "defaultVersion": "1.18.2" + "defaultVersion": "1.18.2", + "mapsProvider": "zardoy.site/maps" } diff --git a/cypress/integration/index.spec.ts b/cypress/integration/index.spec.ts index 4db13ed28..cb3ea3edb 100644 --- a/cypress/integration/index.spec.ts +++ b/cypress/integration/index.spec.ts @@ -1,9 +1,9 @@ /// import type { AppOptions } from '../../src/optionsStorage' -const cleanVisit = () => { +const cleanVisit = (url?) => { window.localStorage.clear() - visit() + visit(url) } const visit = (url = '/') => { @@ -38,7 +38,7 @@ const setOptions = (options: Partial) => { } it('Loads & renders singleplayer', () => { - cleanVisit() + cleanVisit('/?singleplayer=1') setOptions({ localServerOptions: { generation: { @@ -49,7 +49,6 @@ it('Loads & renders singleplayer', () => { }, renderDistance: 2 }) - cy.get('[data-test-id="singleplayer-button"]', { includeShadowDom: true }).click() testWorldLoad() }) diff --git a/experiments/texture-render.html b/experiments/texture-render.html new file mode 100644 index 000000000..be406102a --- /dev/null +++ b/experiments/texture-render.html @@ -0,0 +1,60 @@ + + + + + + + Document + + + + + + + diff --git a/experiments/vite.config.ts b/experiments/vite.config.ts new file mode 100644 index 000000000..e0a30beff --- /dev/null +++ b/experiments/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: 'experiments', +}) diff --git a/index.html b/index.html index dc431ad7d..d3a2bbae4 100644 --- a/index.html +++ b/index.html @@ -34,6 +34,7 @@ + diff --git a/package.json b/package.json index 97ffbaa63..57421db1e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lint": "eslint \"{src,cypress}/**/*.{ts,js,jsx,tsx}\"", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "start-experiments": "vite --config experiments/vite.config.ts", "watch-worker": "node prismarine-viewer/buildWorker.mjs -w" }, "keywords": [ @@ -26,9 +27,10 @@ "license": "MIT", "dependencies": { "@dimaka/interface": "0.0.3-alpha.0", + "@floating-ui/react": "^0.26.1", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", - "@types/wicg-file-system-access": "^2020.9.6", + "@types/wicg-file-system-access": "^2023.10.2", "@zardoy/react-util": "^0.2.0", "@zardoy/utils": "^0.0.11", "browserfs": "github:zardoy/browserfs#build", @@ -55,9 +57,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-transition-group": "^4.4.5", + "sanitize-filename": "^1.6.3", "stats-gl": "^1.0.5", "stats.js": "^0.17.0", - "tippy.js": "^6.3.7", + "tabbable": "^6.2.0", "title-case": "3.x", "valtio": "^1.11.1", "workbox-build": "^7.0.0" @@ -111,6 +114,7 @@ }, "pnpm": { "overrides": { + "diamond-square": "github:zardoy/diamond-square", "prismarine-block": "github:zardoy/prismarine-block#next-era", "prismarine-world": "github:zardoy/prismarine-world#next-era", "minecraft-data": "3.45.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f828fe7c7..a3e5b32af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + diamond-square: github:zardoy/diamond-square prismarine-block: github:zardoy/prismarine-block#next-era prismarine-world: github:zardoy/prismarine-world#next-era minecraft-data: 3.45.0 @@ -19,6 +20,9 @@ importers: '@dimaka/interface': specifier: 0.0.3-alpha.0 version: 0.0.3-alpha.0(@babel/core@7.22.11)(@popperjs/core@2.11.8)(@types/react@18.2.20)(react-dom@18.2.0)(react@18.2.0) + '@floating-ui/react': + specifier: ^0.26.1 + version: 0.26.1(react-dom@18.2.0)(react@18.2.0) '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -26,8 +30,8 @@ importers: specifier: ^18.2.7 version: 18.2.7 '@types/wicg-file-system-access': - specifier: ^2020.9.6 - version: 2020.9.6 + specifier: ^2023.10.2 + version: 2023.10.2 '@zardoy/react-util': specifier: ^0.2.0 version: 0.2.0(react-dom@18.2.0)(react@18.2.0) @@ -63,7 +67,7 @@ importers: version: 4.18.2 flying-squid: specifier: github:zardoy/space-squid#everything - version: github.com/zardoy/space-squid/93f0d264e225bb8526885a8caaa4c547af6fa6eb + version: github.com/zardoy/space-squid/aa59c32fa17c3dbb2c7c28cfb3655b4a7daf0489 fs-extra: specifier: ^11.1.1 version: 11.1.1 @@ -106,15 +110,18 @@ importers: react-transition-group: specifier: ^4.4.5 version: 4.4.5(react-dom@18.2.0)(react@18.2.0) + sanitize-filename: + specifier: ^1.6.3 + version: 1.6.3 stats-gl: specifier: ^1.0.5 version: 1.0.5 stats.js: specifier: ^0.17.0 version: 0.17.0 - tippy.js: - specifier: ^6.3.7 - version: 6.3.7 + tabbable: + specifier: ^6.2.0 + version: 6.2.0 title-case: specifier: 3.x version: 3.0.3 @@ -208,7 +215,7 @@ importers: version: 1.0.0 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next - version: github.com/zardoy/minecraft-inventory-gui/c1331c91fb39bd562dc48eeb33321240d4870edd(@types/react@18.2.20)(react@18.2.0) + version: github.com/zardoy/minecraft-inventory-gui/69003692b3041d94a420a65c7d3cc1b37737e838(@types/react@18.2.20)(react@18.2.0) mineflayer: specifier: github:zardoy/mineflayer#custom version: github.com/zardoy/mineflayer/e828c161aab120f2d926fba48de3b4d57c361710 @@ -2294,14 +2301,12 @@ packages: resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} dependencies: '@floating-ui/utils': 0.1.6 - dev: true /@floating-ui/dom@1.5.3: resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==} dependencies: '@floating-ui/core': 1.5.0 '@floating-ui/utils': 0.1.6 - dev: true /@floating-ui/react-dom@2.0.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==} @@ -2312,11 +2317,22 @@ packages: '@floating-ui/dom': 1.5.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: true + + /@floating-ui/react@0.26.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5gyJIJ2tZOPMgmZ/vEcVhdmQiy75b7LPO71sYIiDsxGcZ4hxLuygQWCuT0YXHqppt//Eese+L6t5KnX/gZ3tVA==} + peerDependencies: + react: ^18.2.0 + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 2.0.2(react-dom@18.2.0)(react@18.2.0) + '@floating-ui/utils': 0.1.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + dev: false /@floating-ui/utils@0.1.6: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} - dev: true /@humanwhocodes/config-array@0.11.11: resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==} @@ -4855,8 +4871,8 @@ packages: resolution: {integrity: sha512-HVOsSRTQYx3zpVl0c0FBmmmcY/60BkQLzVnpE9M1aG4f2Z0aKlBWfj4XZ2zr++XNBfkQWYcwhGlmuu44RJPDqg==} dev: false - /@types/wicg-file-system-access@2020.9.6: - resolution: {integrity: sha512-6hogE75Hl2Ov/jgp8ZhDaGmIF/q3J07GtXf8nCJCwKTHq7971po5+DId7grft09zG7plBwpF6ZU0yx9Du4/e1A==} + /@types/wicg-file-system-access@2023.10.2: + resolution: {integrity: sha512-nSiK8qt0O7sQmDcW3HYfvya7GDoD6ipgdcUFzk3QN+UBIqXeNg38Nh6VnKv7EIPfkVETRiquyMskCbpxUzgX1Q==} dev: false /@types/yargs-parser@21.0.1: @@ -6946,15 +6962,6 @@ packages: - supports-color dev: true - /diamond-square@1.2.0: - resolution: {integrity: sha512-8WKLWV8Bey1UWaDHDocry7JDzpTRBOx/iOktkCoCjmKYcpVFOihIoeeg9CG+n2WIIenRxtyne/DxzsR25bQwpQ==} - dependencies: - minecraft-data: 3.45.0 - prismarine-chunk: 1.35.0(minecraft-data@3.45.0) - random-seed: 0.3.0 - vec3: 0.1.8 - dev: false - /diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -10316,6 +10323,12 @@ packages: engines: {node: '>=10'} hasBin: true + /mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + dev: false + /mlly@1.4.2: resolution: {integrity: sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==} dependencies: @@ -10539,6 +10552,12 @@ packages: dependencies: asn1: 0.2.3 + /node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + dependencies: + asn1: 0.2.6 + dev: false + /nopt@1.0.10: resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==} hasBin: true @@ -12920,6 +12939,10 @@ packages: resolution: {integrity: sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==} dev: true + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + /tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} dependencies: @@ -13106,12 +13129,6 @@ packages: engines: {node: '>=14.0.0'} dev: true - /tippy.js@6.3.7: - resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} - dependencies: - '@popperjs/core': 2.11.8 - dev: false - /title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} dependencies: @@ -14351,9 +14368,20 @@ packages: async: 2.6.4 dev: false - github.com/zardoy/minecraft-inventory-gui/c1331c91fb39bd562dc48eeb33321240d4870edd(@types/react@18.2.20)(react@18.2.0): - resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/c1331c91fb39bd562dc48eeb33321240d4870edd} - id: github.com/zardoy/minecraft-inventory-gui/c1331c91fb39bd562dc48eeb33321240d4870edd + github.com/zardoy/diamond-square/841ccd5345678b3b2a28d645eaf43da2991a5a7d: + resolution: {tarball: https://codeload.github.com/zardoy/diamond-square/tar.gz/841ccd5345678b3b2a28d645eaf43da2991a5a7d} + name: diamond-square + version: 1.3.0 + dependencies: + minecraft-data: 3.45.0 + prismarine-chunk: 1.35.0(minecraft-data@3.45.0) + random-seed: 0.3.0 + vec3: 0.1.8 + dev: false + + github.com/zardoy/minecraft-inventory-gui/69003692b3041d94a420a65c7d3cc1b37737e838(@types/react@18.2.20)(react@18.2.0): + resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/69003692b3041d94a420a65c7d3cc1b37737e838} + id: github.com/zardoy/minecraft-inventory-gui/69003692b3041d94a420a65c7d3cc1b37737e838 name: minecraft-inventory-gui version: 1.0.1 dependencies: @@ -14431,9 +14459,9 @@ packages: prismarine-nbt: 2.2.1 prismarine-registry: 1.7.0 - github.com/zardoy/prismarine-provider-anvil/49bf8150422301565b386110aaf3dec69e31c4cf(minecraft-data@3.45.0): - resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/49bf8150422301565b386110aaf3dec69e31c4cf} - id: github.com/zardoy/prismarine-provider-anvil/49bf8150422301565b386110aaf3dec69e31c4cf + github.com/zardoy/prismarine-provider-anvil/b250d93638a2e300a7d016cfd9e87162347e1406(minecraft-data@3.45.0): + resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/b250d93638a2e300a7d016cfd9e87162347e1406} + id: github.com/zardoy/prismarine-provider-anvil/b250d93638a2e300a7d016cfd9e87162347e1406 name: prismarine-provider-anvil version: 2.7.0 dependencies: @@ -14467,8 +14495,8 @@ packages: - utf-8-validate dev: false - github.com/zardoy/space-squid/93f0d264e225bb8526885a8caaa4c547af6fa6eb: - resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/93f0d264e225bb8526885a8caaa4c547af6fa6eb} + github.com/zardoy/space-squid/aa59c32fa17c3dbb2c7c28cfb3655b4a7daf0489: + resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/aa59c32fa17c3dbb2c7c28cfb3655b4a7daf0489} name: flying-squid version: 1.5.0 engines: {node: '>=8'} @@ -14476,7 +14504,7 @@ packages: dependencies: change-case: 4.1.2 colors: 1.4.0 - diamond-square: 1.2.0 + diamond-square: github.com/zardoy/diamond-square/841ccd5345678b3b2a28d645eaf43da2991a5a7d emit-then: 2.0.0 event-promise: 0.0.1 exit-hook: 2.2.1 @@ -14484,14 +14512,16 @@ packages: long: 5.2.3 minecraft-data: 3.45.0 minecraft-protocol: github.com/zardoy/minecraft-protocol/8e61798aeae786db3ddd4bbd9e19b6e30bb2520c + mkdirp: 2.1.6 moment: 2.29.4 needle: 2.9.1 node-gzip: 1.1.2 + node-rsa: 1.1.1 prismarine-chunk: 1.35.0(minecraft-data@3.45.0) prismarine-entity: 2.3.1 prismarine-item: 1.14.0 prismarine-nbt: 2.2.1 - prismarine-provider-anvil: github.com/zardoy/prismarine-provider-anvil/49bf8150422301565b386110aaf3dec69e31c4cf(minecraft-data@3.45.0) + prismarine-provider-anvil: github.com/zardoy/prismarine-provider-anvil/b250d93638a2e300a7d016cfd9e87162347e1406(minecraft-data@3.45.0) prismarine-windows: 2.8.0 prismarine-world: github.com/zardoy/prismarine-world/c358222204d21fe7d45379fbfcefb047f926c786 random-seed: 0.3.0 diff --git a/prismarine-viewer/examples/playground.js b/prismarine-viewer/examples/playground.js index 73a341eec..c7846b6f4 100644 --- a/prismarine-viewer/examples/playground.js +++ b/prismarine-viewer/examples/playground.js @@ -94,7 +94,7 @@ async function main () { // const data = await fetch('smallhouse1.schem').then(r => r.arrayBuffer()) // const schem = await Schematic.read(Buffer.from(data), version) - const viewDistance = 2 + const viewDistance = 0 const center = new Vec3(0, 90, 0) const World = WorldLoader(version) @@ -132,7 +132,7 @@ async function main () { viewer.listen(worldView) // Load chunks - worldView.init(center) + await worldView.init(center) window['worldView'] = worldView window['viewer'] = viewer @@ -155,13 +155,14 @@ async function main () { } const onUpdate = { block () { + folder.destroy() + const block = mcData.blocksByName[params.block] + if (!block) return + const props = new Block(block.id, 0, 0).getProperties() //@ts-ignore const { states } = mcData.blocksByStateId[getBlock()?.minStateId] ?? {} - folder.destroy() - if (!states) { - return - } folder = gui.addFolder('metadata') + if (states) { for (const state of states) { let defaultValue switch (state.type) { @@ -186,6 +187,12 @@ async function main () { folder.add(blockProps, state.name, state.values) } else { folder.add(blockProps, state.name) + } + } + } else { + for (const [name, value] of Object.entries(props)) { + blockProps[name] = value + folder.add(blockProps, name) } } folder.open() @@ -214,8 +221,13 @@ async function main () { child.updateDisplay() } } else { + try { //@ts-ignore block = Block.fromProperties(blockId ?? -1, blockProps, 0) + } catch (err) { + console.error(err) + block = Block.fromStateId(0, 0) + } } //@ts-ignore diff --git a/prismarine-viewer/viewer/lib/models.ts b/prismarine-viewer/viewer/lib/models.ts index 468de0e33..d391808f0 100644 --- a/prismarine-viewer/viewer/lib/models.ts +++ b/prismarine-viewer/viewer/lib/models.ts @@ -393,14 +393,14 @@ export function getSectionGeometry (sx, sy, sz, world: World) { const block = world.getBlock(cursor) if (block.name.includes('sign')) { const key = `${cursor.x},${cursor.y},${cursor.z}` - const props = block.getProperties(); + const props = block.getProperties() const facingRotationMap = { "north": 2, "south": 0, "west": 1, "east": 3 } - const isWall = block.name.endsWith('wall_sign') || block.name.endsWith('hanging_sign'); + const isWall = block.name.endsWith('wall_sign') || block.name.endsWith('hanging_sign') attr.signs[key] = { isWall, rotation: isWall ? facingRotationMap[props.facing] : +props.rotation @@ -484,7 +484,7 @@ function parseProperties (properties) { return json } -function matchProperties (block, properties) { +function matchProperties (block, /* to match against */properties: Record) { if (!properties) { return true } properties = parseProperties(properties) @@ -493,17 +493,21 @@ function matchProperties (block, properties) { return properties.OR.some((or) => matchProperties(block, or)) } for (const prop in blockProps) { - if (typeof properties[prop] === 'string' && !properties[prop].split('|').some((value) => value === blockProps[prop] + '')) { + if (properties[prop] === undefined) continue // unknown property, ignore + if (typeof properties[prop] !== 'string') properties[prop] = String(properties[prop]) + if (!properties[prop].split('|').some((value) => value === String(blockProps[prop]))) { return false } } return true } -function getModelVariants (block) { +function getModelVariants (block: import('prismarine-block').Block) { // air, cave_air, void_air and so on... - if (block.name === 'air' || block.name.endsWith('_air')) return [] - const state = blockStates[block.name] ?? blockStates.missing_texture + if (block.name === '' || block.name === 'air' || block.name.endsWith('_air')) return [] + const matchedState = blockStates[block.name] + // if (!matchedState) currentWarnings.value.add(`Missing block ${block.name}`) + const state = matchedState ?? blockStates.missing_texture if (!state) return [] if (state.variants) { for (const [properties, variant] of Object.entries(state.variants)) { @@ -516,7 +520,7 @@ function getModelVariants (block) { const parts = state.multipart.filter(multipart => matchProperties(block, multipart.when)) let variants = [] for (const part of parts) { - variants = [...variants, ...Array.isArray(part.apply) ? part.apply : [part.apply]]; + variants = [...variants, ...Array.isArray(part.apply) ? part.apply : [part.apply]] } return variants diff --git a/prismarine-viewer/viewer/lib/worldrenderer.js b/prismarine-viewer/viewer/lib/worldrenderer.js index 60c52921c..288255b31 100644 --- a/prismarine-viewer/viewer/lib/worldrenderer.js +++ b/prismarine-viewer/viewer/lib/worldrenderer.js @@ -28,8 +28,10 @@ class WorldRenderer { this.loadedChunks = {} this.sectionsOutstanding = new Set() this.renderUpdateEmitter = new EventEmitter() - this.blockStatesData = undefined - this.texturesDataUrl = undefined + this.customBlockStatesData = undefined + this.customTexturesDataUrl = undefined + this.downloadedBlockStatesData = undefined + this.downloadedTextureImage = undefined this.material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 }) @@ -156,17 +158,25 @@ class WorldRenderer { } updateTexturesData () { - loadTexture(this.texturesDataUrl || `textures/${this.texturesVersion}.png`, texture => { + loadTexture(this.customTexturesDataUrl || `textures/${this.texturesVersion}.png`, texture => { texture.magFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter texture.flipY = false this.material.map = texture + this.material.map.onUpdate = () => { + this.downloadedTextureImage = this.material.map.image + } }) const loadBlockStates = async () => { return new Promise(resolve => { - if (this.blockStatesData) return resolve(this.blockStatesData) - return loadJSON(`blocksStates/${this.texturesVersion}.json`, resolve) + if (this.customBlockStatesData) return resolve(this.customBlockStatesData) + return loadJSON(`blocksStates/${this.texturesVersion}.json`, (data) => { + this.downloadedBlockStatesData = data + // todo + this.renderUpdateEmitter.emit('blockStatesDownloaded') + resolve(data) + }) }) } loadBlockStates().then((blockStates) => { diff --git a/prismarine-viewer/viewer/prepare/atlas.ts b/prismarine-viewer/viewer/prepare/atlas.ts index 3a8eed60f..7399b196e 100644 --- a/prismarine-viewer/viewer/prepare/atlas.ts +++ b/prismarine-viewer/viewer/prepare/atlas.ts @@ -2,6 +2,7 @@ import fs from 'fs' import path from 'path' import { Canvas, Image } from 'canvas' import { getAdditionalTextures } from './moreGeneratedBlocks' +import { McAssets } from './modelsBuilder' function nextPowerOfTwo (n) { if (n === 0) return 1 @@ -24,15 +25,24 @@ function readTexture (basePath, name) { return fs.readFileSync(path.join(basePath, name), 'base64') } -export function makeTextureAtlas (mcAssets) { - const blocksTexturePath = path.join(mcAssets.directory, '/blocks') - const textureFiles = fs.readdirSync(blocksTexturePath).filter(file => file.endsWith('.png')) - textureFiles.unshift(...localTextures) - - const { generated: additionalTextures, twoBlockTextures } = getAdditionalTextures() - textureFiles.push(...Object.keys(additionalTextures)) +export type JsonAtlas = { + size: number, + textures: { + [file: string]: { + u: number, + v: number, + su: number, + sv: number + } + } +} - const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length + twoBlockTextures.length))) +export const makeTextureAtlas = (input: string[], getInputData: (name) => {contents: string, tileWidthMult?: number}, tilesCount = input.length, suSvOptimize: 'remove' | null = null): { + image: Buffer, + canvas: Canvas, + json: JsonAtlas +} => { + const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(tilesCount))) const tileSize = 16 const imgSize = texSize * tileSize @@ -42,28 +52,62 @@ export function makeTextureAtlas (mcAssets) { const texturesIndex = {} let offset = 0 - for (const i in textureFiles) { + const suSv = tileSize / imgSize + for (const i in input) { const pos = +i + offset const x = (pos % texSize) * tileSize const y = Math.floor(pos / texSize) * tileSize - const name = textureFiles[i].split('.')[0] - const img = new Image() - if (additionalTextures[name]) { - img.src = additionalTextures[name] - } else { - img.src = 'data:image/png;base64,' + readTexture(blocksTexturePath, textureFiles[i]) - } - const twoTileWidth = twoBlockTextures.includes(name) - if (twoTileWidth) { - offset++ - } - const renderWidth = twoTileWidth ? tileSize * 2 : tileSize + const keyValue = input[i]; + const inputData = getInputData(keyValue); + img.src = inputData.contents + const renderWidth = tileSize * (inputData.tileWidthMult ?? 1) g.drawImage(img, 0, 0, renderWidth, tileSize, x, y, renderWidth, tileSize) - texturesIndex[name] = { u: x / imgSize, v: y / imgSize, su: tileSize / imgSize, sv: tileSize / imgSize } + const cleanName = keyValue.split('.').slice(0, -1).join('.') || keyValue + texturesIndex[cleanName] = { + u: x / imgSize, + v: y / imgSize, + ...suSvOptimize === 'remove' ? {} : { + su: suSv, + sv: suSv + } + } } - return { image: canvas.toBuffer(), canvas, json: { size: tileSize / imgSize, textures: texturesIndex } } + return { image: canvas.toBuffer(), canvas, json: { size: suSv, textures: texturesIndex } } +} + +export const writeCanvasStream = (canvas, path, onEnd) => { + const out = fs.createWriteStream(path) + const stream = (canvas as any).pngStream() + stream.on('data', (chunk) => out.write(chunk)) + if (onEnd) stream.on('end', onEnd) + return stream +} + +export function makeBlockTextureAtlas (mcAssets: McAssets) { + const blocksTexturePath = path.join(mcAssets.directory, '/blocks') + const textureFiles = fs.readdirSync(blocksTexturePath).filter(file => file.endsWith('.png')) + // const textureFiles = mostEncounteredBlocks.map(x => x + '.png') + textureFiles.unshift(...localTextures) + + const { generated: additionalTextures, twoTileTextures } = getAdditionalTextures() + textureFiles.push(...Object.keys(additionalTextures)) + + const atlas = makeTextureAtlas(textureFiles, name => { + let contents: string + if (additionalTextures[name]) { + contents = additionalTextures[name] + } else { + contents = 'data:image/png;base64,' + readTexture(blocksTexturePath, name) + } + + return { + contents, + tileWidthMult: twoTileTextures.includes(name) ? 2 : undefined, + } + }) + return atlas } diff --git a/prismarine-viewer/viewer/prepare/genItemsAtlas.ts b/prismarine-viewer/viewer/prepare/genItemsAtlas.ts new file mode 100644 index 000000000..62ddc41d7 --- /dev/null +++ b/prismarine-viewer/viewer/prepare/genItemsAtlas.ts @@ -0,0 +1,148 @@ +import fs from 'fs' +import McAssets from 'minecraft-assets' +import { join } from 'path' +import { filesize } from 'filesize' +import minecraftDataLoader from 'minecraft-data' +import BlockLoader from 'prismarine-block' +import { JsonAtlas, makeTextureAtlas, writeCanvasStream } from './atlas' +import looksSame from 'looks-same' // ensure after canvas import +import { Version as _Version } from 'minecraft-data' +import { versionToNumber } from './utils' + +// todo move it, remove it +const legacyInvsprite = JSON.parse(fs.readFileSync(join(__dirname, '../../../src/invsprite.json'), 'utf8')) + +//@ts-ignore +const latestMcAssetsVersion = McAssets.versions.at(-1) +// const latestVersion = minecraftDataLoader.supportedVersions.pc.at(-1) +const mcData = minecraftDataLoader(latestMcAssetsVersion) +const PBlock = BlockLoader(latestMcAssetsVersion) + +function isCube (name) { + const id = mcData.blocksByName[name]?.id + if (!id) return + const block = new PBlock(id, 0, 0) + const shape = block.shapes?.[0] + return block.shapes?.length === 1 && shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 +} + +export type ItemsAtlasesOutputJson = { + latest: JsonAtlas + legacy: JsonAtlas + legacyMap: [string, string[]][] +} + +export const generateItemsAtlases = async () => { + const latestAssets = McAssets(latestMcAssetsVersion) + const latestItems = fs.readdirSync(join(latestAssets.directory, 'items')).map(f => f.split('.')[0]) + + // item - texture path + const toAddTextures = { + fromBlocks: {} as Record, + remapItems: {} as Record, // todo + } + + const getItemTextureOfBlock = (name: string) => { + const blockModel = latestAssets.blocksModels[name] + // const isPlainBlockDisplay = blockModel?.display?.gui?.rotation?.[0] === 0 && blockModel?.display?.gui?.rotation?.[1] === 0 && blockModel?.display?.gui?.rotation?.[2] === 0 + // it seems that information about cross blocks is hardcoded + if (blockModel?.parent?.endsWith('block/cross')) { + toAddTextures.fromBlocks[name] = `blocks/${blockModel.textures.cross.split('/')[1]}` + return true + } + + if (legacyInvsprite[name]) { + return true + } + + if (fs.existsSync(join(latestAssets.directory, 'blocks', name + '.png'))) { + // very last resort + toAddTextures.fromBlocks[name] = `blocks/${name}` + return true + } + if (name.endsWith('_spawn_egg')) { + // todo also color + toAddTextures.fromBlocks[name] = `items/spawn_egg` + } + } + + for (const item of mcData.itemsArray) { + if (latestItems.includes(item.name)) { + continue + } + // USE IN RUNTIME + if (isCube(item.name)) { + // console.log('cube', block.name) + } else if (!getItemTextureOfBlock(item.name)) { + console.warn('skipping item (not cube, no item texture)', item.name) + } + } + + let fullItemsMap = {} as Record + + const itemsSizes = {} + let saving = 0 + let overallsize = 0 + let prevItemsDir + let prevVersion + for (const version of [...McAssets.versions].reverse()) { + const itemsDir = join(McAssets(version).directory, 'items') + for (const item of fs.readdirSync(itemsDir)) { + const prevItemPath = !prevItemsDir ? undefined : join(prevItemsDir, item) + const itemSize = fs.statSync(join(itemsDir, item)).size + if (prevItemPath && fs.existsSync(prevItemPath) && (await looksSame(join(itemsDir, item), prevItemPath, { strict: true })).equal) { + saving += itemSize + } else { + fullItemsMap[version] ??= [] + fullItemsMap[version].push(item) + } + overallsize += itemSize + } + prevItemsDir = itemsDir + prevVersion = version + } + + fullItemsMap = Object.fromEntries(Object.entries(fullItemsMap).map(([ver, items]) => [ver, items.filter(item => item.endsWith('.png'))])) + const latestVersionItems = fullItemsMap[latestMcAssetsVersion] + delete fullItemsMap[latestMcAssetsVersion] + const legacyItemsSortedEntries = Object.entries(fullItemsMap).sort(([a], [b]) => versionToNumber(a) - versionToNumber(b)).map(([key, value]) => [key, value.map(x => x.replace('.png', ''))] as [typeof key, typeof value]) + // const allItemsLength = Object.values(fullItemsMap).reduce((acc, x) => acc + x.length, 0) + // console.log(`Items to generate: ${allItemsLength} (latest version: ${latestVersionItems.length})`) + const fullLatestItemsObject = { + ...Object.fromEntries(latestVersionItems.map(item => [item, `items/${item.replace('.png', '')}`])), + ...toAddTextures.fromBlocks, + ...toAddTextures.remapItems + } + + const latestAtlas = makeTextureAtlas(Object.keys(fullLatestItemsObject), (name) => { + const contents = `data:image/png;base64,${fs.readFileSync(join(latestAssets.directory, `${fullLatestItemsObject[name]}.png`), 'base64')}` + return { + contents, + } + }, undefined, 'remove') + const texturesPath = join(__dirname, '../../public/textures') + writeCanvasStream(latestAtlas.canvas, join(texturesPath, 'items.png'), () => { + console.log('Generated latest items atlas') + }) + + const legacyItemsMap = legacyItemsSortedEntries.flatMap(([ver, items]) => items.map(item => `${ver}-${item}.png`)) + const legacyItemsAtlas = makeTextureAtlas(legacyItemsMap, (name) => { + const [ver, item] = name.split('-') + const contents = `data:image/png;base64,${fs.readFileSync(join(McAssets(ver).directory, `items/${item}`), 'base64')}` + return { + contents, + } + }, undefined, 'remove') + writeCanvasStream(legacyItemsAtlas.canvas, join(texturesPath, 'items-legacy.png'), () => { + console.log('Generated legacy items atlas') + }) + + const allItemsMaps: ItemsAtlasesOutputJson = { + latest: latestAtlas.json, + legacy: legacyItemsAtlas.json, + legacyMap: legacyItemsSortedEntries + } + fs.writeFileSync(join(texturesPath, 'items.json'), JSON.stringify(allItemsMaps), 'utf8') + + console.log(`Generated items! Input size: ${filesize(overallsize)}, saving: ~${filesize(saving)}`) +} diff --git a/prismarine-viewer/viewer/prepare/generateTextures.ts b/prismarine-viewer/viewer/prepare/generateTextures.ts index 380ac96ac..b9a5e3e5f 100644 --- a/prismarine-viewer/viewer/prepare/generateTextures.ts +++ b/prismarine-viewer/viewer/prepare/generateTextures.ts @@ -1,9 +1,10 @@ import path from 'path' -import { makeTextureAtlas } from './atlas' +import { makeBlockTextureAtlas } from './atlas' import { McAssets, prepareBlocksStates } from './modelsBuilder' import mcAssets from 'minecraft-assets' import fs from 'fs-extra' import { prepareMoreGeneratedBlocks } from './moreGeneratedBlocks' +import { generateItemsAtlases } from './genItemsAtlas' const publicPath = path.resolve(__dirname, '../../public') @@ -19,17 +20,18 @@ fs.mkdirSync(blockStatesPath, { recursive: true }) const warnings = new Set() Promise.resolve().then(async () => { + generateItemsAtlases() console.time('generateTextures') for (const version of mcAssets.versions as typeof mcAssets['versions']) { // for debugging (e.g. when above is overridden) if (!mcAssets.versions.includes(version)) { - throw new Error(`Version ${version} is not supported by minecraft-assets, skipping...`) + throw new Error(`Version ${version} is not supported by minecraft-assets`) } const assets = mcAssets(version) const { warnings: _warnings } = await prepareMoreGeneratedBlocks(assets) _warnings.forEach(x => warnings.add(x)) // #region texture atlas - const atlas = makeTextureAtlas(assets) + const atlas = makeBlockTextureAtlas(assets) const out = fs.createWriteStream(path.resolve(texturesPath, version + '.png')) const stream = (atlas.canvas as any).pngStream() stream.on('data', (chunk) => out.write(chunk)) diff --git a/prismarine-viewer/viewer/prepare/modelsBuilder.ts b/prismarine-viewer/viewer/prepare/modelsBuilder.ts index e32903fed..8a68b9f0b 100644 --- a/prismarine-viewer/viewer/prepare/modelsBuilder.ts +++ b/prismarine-viewer/viewer/prepare/modelsBuilder.ts @@ -162,7 +162,7 @@ function prepareModel (model: BlockModel, texturesJson) { const getFinalTexture = (originalBlockName) => { // texture name e.g. blocks/anvil_base const cleanBlockName = cleanupBlockName(originalBlockName); - return { ...texturesJson[cleanBlockName], __debugName: cleanBlockName } + return { ...texturesJson[cleanBlockName], /* __debugName: cleanBlockName */ } } const finalTextures = [] diff --git a/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts b/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts index 67b024cef..863d62835 100644 --- a/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts +++ b/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts @@ -7,7 +7,7 @@ import fs from 'fs' import { fileURLToPath } from 'url' // todo refactor -const twoBlockTextures: string[] = [] +const twoTileTextures: string[] = [] let currentImage: Jimp let currentBlockName: string let currentMcAssets: McAssets @@ -228,8 +228,8 @@ const handleSign = async (dataBase: string, match: RegExpExecArray) => { ], } } - twoBlockTextures.push(blockTextures.face.texture) - twoBlockTextures.push(blockTextures.up.texture) + twoTileTextures.push(blockTextures.face.texture) + twoTileTextures.push(blockTextures.up.texture) } const chestModels = { @@ -472,5 +472,5 @@ export const prepareMoreGeneratedBlocks = async (mcAssets: McAssets) => { } export const getAdditionalTextures = () => { - return { generated: generatedImageTextures, twoBlockTextures } + return { generated: generatedImageTextures, twoTileTextures } } diff --git a/prismarine-viewer/viewer/prepare/utils.ts b/prismarine-viewer/viewer/prepare/utils.ts new file mode 100644 index 000000000..a33909a9d --- /dev/null +++ b/prismarine-viewer/viewer/prepare/utils.ts @@ -0,0 +1,4 @@ +export const versionToNumber = (ver: string) => { + const [x, y = '0', z = '0'] = ver.split('.') + return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}` +} diff --git a/prismarine-viewer/viewer/sign-renderer/index.ts b/prismarine-viewer/viewer/sign-renderer/index.ts index 164c01a84..5eb6a516c 100644 --- a/prismarine-viewer/viewer/sign-renderer/index.ts +++ b/prismarine-viewer/viewer/sign-renderer/index.ts @@ -56,8 +56,8 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ] const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black' for (let [lineNum, text] of texts.slice(0, 4).entries()) { - // todo test mojangson parsing - const parsed = parseSafe(text ?? '""', 'sign text') + // todo: in pre flatenning it seems the format was not json + const parsed = text.startsWith('{') ? parseSafe(text ?? '""', 'sign text') : text if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue // todo fix type const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never @@ -81,7 +81,8 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof text: string }[] = [] let plainText = '' - const MAX_LENGTH = 15 // avoid abusing the signboard + // todo the text should be clipped based on it's render width (needs investigate) + const MAX_LENGTH = 50 // avoid abusing the signboard const renderText = (node: RenderNode) => { const { component } = node let { text } = component diff --git a/scripts/build.js b/scripts/build.js index f73a2a60c..547bc8114 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -5,8 +5,10 @@ const glob = require('glob') const fs = require('fs') const crypto = require('crypto') const path = require('path') +const McAssets = require('minecraft-assets') const prismarineViewerBase = "./node_modules/prismarine-viewer" +const entityMcAssets = McAssets('1.16.4') // these files could be copied at build time eg with copy plugin, but copy plugin slows down the config so we copy them there, alternative we could inline it in esbuild config const filesToCopy = [ @@ -14,7 +16,7 @@ const filesToCopy = [ { from: `${prismarineViewerBase}/public/worker.js`, to: 'dist/worker.js' }, { from: './assets/', to: './dist/' }, { from: './config.json', to: 'dist/config.json' }, - { from: `${prismarineViewerBase}/public/textures/1.16.4/entity`, to: 'dist/textures/1.16.4/entity' }, + { from: path.join(entityMcAssets.directory, 'entity'), to: 'dist/textures/1.16.4/entity' }, ] exports.filesToCopy = filesToCopy exports.copyFiles = (dev = false) => { diff --git a/scripts/esbuildPlugins.mjs b/scripts/esbuildPlugins.mjs index ed5a092f0..a2247632e 100644 --- a/scripts/esbuildPlugins.mjs +++ b/scripts/esbuildPlugins.mjs @@ -107,13 +107,15 @@ const plugins = [ setup (build) { let count = 0 let time + let prevHash build.onStart(() => { time = Date.now() }) build.onEnd(({ errors, outputFiles: _outputFiles, metafile, warnings }) => { - /** @type {any} */ + /** @type {import('esbuild').OutputFile[]} */ const outputFiles = _outputFiles const elapsed = Date.now() - time + outputFiles.find(outputFile => outputFile.path) if (errors.length) { connectedClients.forEach((res) => { @@ -127,6 +129,11 @@ const plugins = [ // fs.writeFileSync('dist/meta.json', JSON.stringify(metafile, null, 2)) const outputFile = outputFiles.find(x => x.path.endsWith('.js')) + if (outputFile.hash === prevHash) { + console.log('Ignoring reload as contents the same') + return + } + prevHash = outputFile.hash let outputText = outputFile.text //@ts-ignore if (['inline', 'both'].includes(build.initialOptions.sourcemap)) { diff --git a/src/browserfs.ts b/src/browserfs.ts index 0bd57385c..ed528a4bf 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -1,28 +1,29 @@ -//@ts-check import { join } from 'path' import { promisify } from 'util' import fs from 'fs' +import sanitizeFilename from 'sanitize-filename' import { oneOf } from '@zardoy/utils' -import JSZip from 'jszip' import * as browserfs from 'browserfs' -import { options } from './optionsStorage' +import { options, resetOptions } from './optionsStorage' import { fsState, loadSave } from './loadSave' -import { installTexturePack, updateTexturePackInstalledState } from './texturePack' +import { installTexturePackFromHandle, updateTexturePackInstalledState } from './texturePack' +import { miscUiState } from './globalState' +import { setLoadingScreenStatus } from './utils' browserfs.install(window) -// todo migrate to StorageManager API for localsave as localstorage has only 5mb limit, when localstorage is fallback test limit warning on 4mb -const deafultMountablePoints = { - '/world': { fs: 'LocalStorage' }, - '/userData': { fs: 'IndexedDB' }, +const defaultMountablePoints = { + '/world': { fs: 'LocalStorage' }, // will be removed in future + '/data': { fs: 'IndexedDB' }, } browserfs.configure({ fs: 'MountableFileSystem', - options: deafultMountablePoints, -}, (e) => { + options: defaultMountablePoints, +}, async (e) => { // todo disable singleplayer button if (e) throw e - void updateTexturePackInstalledState() + await updateTexturePackInstalledState() + miscUiState.appLoaded = true }) export const forceCachedDataPaths = {} @@ -69,7 +70,7 @@ fs.promises.open = async (...args) => { fs[x](fd, ...args, (err, bytesRead, buffer) => { if (err) throw err // todo if readonly probably there is no need to open at all (return some mocked version - check reload)? - if (x === 'write' && !fsState.isReadonly && fsState.syncFs) { + if (x === 'write' && !fsState.isReadonly) { // flush data, though alternatively we can rely on close in unload fs.fsync(fd, () => { }) } @@ -111,6 +112,69 @@ const removeFileRecursiveSync = (path) => { window.removeFileRecursiveSync = removeFileRecursiveSync +export const mkdirRecursive = async (path: string) => { + const parts = path.split('/') + let current = '' + for (const part of parts) { + current += part + '/' + try { + // eslint-disable-next-line no-await-in-loop + await fs.promises.mkdir(current) + } catch (err) { + } + } +} + +export const uniqueFileNameFromWorldName = async (title: string, savePath: string) => { + const name = sanitizeFilename(title) + let resultPath: string + // getUniqueFolderName + let i = 0 + let free = false + while (!free) { + try { + resultPath = `${savePath.replace(/\$/, '')}/${name}${i === 0 ? '' : `-${i}`}` + // eslint-disable-next-line no-await-in-loop + await fs.promises.stat(resultPath) + i++ + } catch (err) { + free = true + } + } + return resultPath +} + +export const mountExportFolder = async () => { + let handle: FileSystemDirectoryHandle + try { + handle = await showDirectoryPicker({ + id: 'world-export', + }) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + throw err + } + if (!handle) return false + await new Promise(resolve => { + browserfs.configure({ + fs: 'MountableFileSystem', + options: { + ...defaultMountablePoints, + '/export': { + fs: 'FileSystemAccess', + options: { + handle + } + } + }, + }, (e) => { + if (e) throw e + resolve() + }) + }) + return true +} + export async function removeFileRecursiveAsync (path) { const errors = [] try { @@ -152,7 +216,9 @@ export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHa _directoryHandle = dragndropHandle } else { try { - _directoryHandle = await window.showDirectoryPicker() + _directoryHandle = await window.showDirectoryPicker({ + id: 'select-world', // important: this is used to remember user choice (start directory) + }) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return throw err @@ -167,9 +233,9 @@ export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHa if (!doContinue) return await new Promise(resolve => { browserfs.configure({ - // todo fs: 'MountableFileSystem', options: { + ...defaultMountablePoints, '/world': { fs: 'FileSystemAccess', options: { @@ -186,19 +252,20 @@ export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHa fsState.isReadonly = !writeAccess fsState.syncFs = false fsState.inMemorySave = false - loadSave() + await loadSave() } -const tryToDetectResourcePack = async (file: File | ArrayBuffer) => { +const tryToDetectResourcePack = async () => { const askInstall = async () => { - return alert('ATM You can install texturepacks only via options menu. WIll be fixed') + // todo investigate browserfs read errors + return alert('ATM You can install texturepacks only via options menu.') // if (confirm('Resource pack detected, do you want to install it?')) { - // await installTexturePack(file) + // await installTexturePackFromHandle() // } } if (fs.existsSync('/world/pack.mcmeta')) { - askInstall() + await askInstall() return true } // const jszip = new JSZip() @@ -211,18 +278,68 @@ const tryToDetectResourcePack = async (file: File | ArrayBuffer) => { // loaded = null } -export const possiblyCleanHandle = () => { +export const possiblyCleanHandle = (callback = () => { }) => { if (!fsState.saveLoaded) { // todo clean handle browserfs.configure({ fs: 'MountableFileSystem', - options: deafultMountablePoints, + options: defaultMountablePoints, }, (e) => { + callback() if (e) throw e }) } } +export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: string) => { + try { + setLoadingScreenStatus('Copying files...') + let filesCount = 0 + const countFiles = async (path: string) => { + const files = await fs.promises.readdir(path) + await Promise.all(files.map(async (file) => { + const curPath = join(path, file) + const stats = await fs.promises.stat(curPath) + if (stats.isDirectory()) { + // Recurse + await countFiles(curPath) + } else { + filesCount++ + } + })) + } + await countFiles(pathSrc) + let copied = 0 + await copyFilesAsync(pathSrc, pathDest, (name) => { + copied++ + setLoadingScreenStatus(`Copying files (${copied}/${filesCount}) ${name}...`) + }) + } finally { + setLoadingScreenStatus(undefined) + } +} + +export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopied?: (name) => void) => { + // query: can't use fs.copy! use fs.promises.writeFile and readFile + const files = await fs.promises.readdir(pathSrc) + + // Use Promise.all to parallelize file/directory copying + await Promise.all(files.map(async (file) => { + const curPathSrc = join(pathSrc, file) + const curPathDest = join(pathDest, file) + const stats = await fs.promises.stat(curPathSrc) + if (stats.isDirectory()) { + // Recurse + await fs.promises.mkdir(curPathDest) + await copyFilesAsync(curPathSrc, curPathDest, fileCopied) + } else { + // Copy file + await fs.promises.writeFile(curPathDest, await fs.promises.readFile(curPathSrc)) + fileCopied?.(file) + } + })) +} + // todo rename method const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) => { await new Promise(async resolve => { @@ -230,7 +347,7 @@ const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) // todo fs: 'MountableFileSystem', options: { - ...deafultMountablePoints, + ...defaultMountablePoints, '/world': { fs: 'ZipFS', options: { @@ -262,7 +379,7 @@ const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) } if (availableWorlds.length === 0) { - if (await tryToDetectResourcePack(file)) return + if (await tryToDetectResourcePack()) return alert('No worlds found in the zip') return } @@ -294,4 +411,13 @@ export const resetLocalStorageWorld = () => { } } +export const resetLocalStorageWithoutWorld = () => { + for (const key of Object.keys(localStorage)) { + if (!/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) && key !== '/') { + localStorage.removeItem(key) + } + } + resetOptions() +} + window.resetLocalStorageWorld = resetLocalStorageWorld diff --git a/src/builtinCommands.ts b/src/builtinCommands.ts index ab92f8735..5eb554432 100644 --- a/src/builtinCommands.ts +++ b/src/builtinCommands.ts @@ -1,10 +1,11 @@ import fs from 'fs' import { join } from 'path' import JSZip from 'jszip' -import { fsState } from './loadSave' +import { fsState, readLevelDat } from './loadSave' import { closeWan, openToWanAndCopyJoinLink } from './localServerMultiplayer' -import { resetLocalStorageWorld } from './browserfs' +import { copyFilesAsync, resetLocalStorageWorld, uniqueFileNameFromWorldName } from './browserfs' import { saveServer } from './flyingSquidUtils' +import { setLoadingScreenStatus } from './utils' const notImplemented = () => { return 'Not implemented yet' @@ -29,30 +30,49 @@ async function addFolderToZip (folderPath, zip, relativePath) { } } +export const exportWorld = async (path: string, type: 'zip' | 'folder', zipName = 'world-prismarine-exported') => { + try { + if (type === 'zip') { + setLoadingScreenStatus('Generating zip, this may take a few minutes') + const zip = new JSZip() + await addFolderToZip(path, zip, '') + + // Generate the ZIP archive content + const zipContent = await zip.generateAsync({ type: 'blob' }) + + // Create a download link and trigger the download + const downloadLink = document.createElement('a') + downloadLink.href = URL.createObjectURL(zipContent) + // todo use loaded zip/folder name + downloadLink.download = `${zipName}.zip` + downloadLink.click() + + // Clean up the URL object after download + URL.revokeObjectURL(downloadLink.href) + } else { + setLoadingScreenStatus('Preparing export folder') + let dest = '/' + if ((await fs.promises.readdir('/export')).length) { + const { levelDat } = await readLevelDat(path) + dest = await uniqueFileNameFromWorldName(levelDat.LevelName, path) + } + setLoadingScreenStatus(`Copying files to ${dest} of selected folder`) + await copyFilesAsync(path, '/export' + dest) + } + } finally { + setLoadingScreenStatus(undefined) + } +} // todo include in help -const exportWorld = async () => { - // todo issue into chat warning if fs is writable! - const zip = new JSZip() +const exportLoadedWorld = async () => { + await saveServer() let { worldFolder } = localServer.options if (!worldFolder.startsWith('/')) worldFolder = `/${worldFolder}` - await addFolderToZip(worldFolder, zip, '') - - // Generate the ZIP archive content - const zipContent = await zip.generateAsync({ type: 'blob' }) - - // Create a download link and trigger the download - const downloadLink = document.createElement('a') - downloadLink.href = URL.createObjectURL(zipContent) - // todo use loaded zip/folder name - downloadLink.download = 'world-prismarine-exported.zip' - downloadLink.click() - - // Clean up the URL object after download - URL.revokeObjectURL(downloadLink.href) + await exportWorld(worldFolder, 'zip') } -window.exportWorld = exportWorld +window.exportWorld = exportLoadedWorld const writeText = (text) => { bot._client.emit('chat', { @@ -63,7 +83,7 @@ const writeText = (text) => { const commands = [ { command: ['/download', '/export'], - invoke: exportWorld + invoke: exportLoadedWorld }, { command: ['/publish', '/share'], diff --git a/src/controls.ts b/src/controls.ts index c4278882d..2b3d70917 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -6,9 +6,10 @@ import { proxy, subscribe } from 'valtio' import { ControMax } from 'contro-max/build/controMax' import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types' import { stringStartsWith } from 'contro-max/build/stringUtils' -import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal } from './globalState' -import { reloadChunks } from './utils' +import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState } from './globalState' +import { goFullscreen, pointerLock, reloadChunks } from './utils' import { options } from './optionsStorage' +import { openPlayerInventory } from './playerWindows' // doesnt seem to work for now const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) @@ -66,6 +67,7 @@ const setSprinting = (state: boolean) => { } contro.on('movementUpdate', ({ vector, gamepadIndex }) => { + miscUiState.usingGamepadInput = gamepadIndex !== undefined // gamepadIndex will be used for splitscreen in future const coordToAction = [ ['z', -1, 'forward'], @@ -166,7 +168,7 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => { // im still not sure, maybe need to refactor to handle in inventory instead const alwaysHandledCommand = (command: Command) => { if (command === 'general.inventory') { - if (activeModalStack.at(-1)?.reactType === 'inventory') { + if (activeModalStack.at(-1)?.reactType?.startsWith?.('player_win:')) { // todo? hideCurrentModal() } } @@ -198,7 +200,7 @@ contro.on('trigger', ({ command }) => { switch (command) { case 'general.inventory': document.exitPointerLock?.() - showModal({ reactType: 'inventory' }) + openPlayerInventory() break case 'general.drop': if (bot.heldItem) bot.tossStack(bot.heldItem) @@ -278,7 +280,7 @@ const startFlyLoop = () => { endFlyLoop?.() endFlyLoop = makeInterval(() => { - if (!window.bot) endFlyLoop() + if (!bot) endFlyLoop() bot.entity.position.add(currentFlyVector.clone().multiply(new Vec3(0, 0.5, 0))) }, 50) } @@ -307,29 +309,63 @@ const patchedSetControlState = (action, state) => { currentFlyVector.add(toAddVec) } +const startFlying = (sendAbilities = true) => { + if (sendAbilities) { + bot._client.write('abilities', { + flags: 2, + }) + } + // window.flyingSpeed will be removed + bot.physics['airborneAcceleration'] = window.flyingSpeed ?? 0.1 // todo use abilities + bot.entity.velocity = new Vec3(0, 0, 0) + bot.creative.startFlying() + startFlyLoop() +} + +const endFlying = (sendAbilities = true) => { + if (bot.physics.gravity !== 0) return + if (sendAbilities) { + bot._client.write('abilities', { + flags: 0, + }) + } + bot.physics['airborneAcceleration'] = standardAirborneAcceleration + bot.creative.stopFlying() + endFlyLoop?.() +} + +let allowFlying = false + +export const onBotCreate = () => { + bot._client.on('abilities', ({ flags }) => { + allowFlying = !!(flags & 4) + if (flags & 2) { // flying + toggleFly(true, false) + } else { + toggleFly(false, false) + } + }) +} + const standardAirborneAcceleration = 0.02 -const toggleFly = () => { - if (bot.game.gameMode !== 'creative' && bot.game.gameMode !== 'spectator') return +const toggleFly = (newState = !isFlying(), sendAbilities?: boolean) => { + // if (bot.game.gameMode !== 'creative' && bot.game.gameMode !== 'spectator') return + if (!allowFlying) return if (bot.setControlState !== patchedSetControlState) { originalSetControlState = bot.setControlState bot.setControlState = patchedSetControlState } - if (isFlying()) { - bot.physics['airborneAcceleration'] = standardAirborneAcceleration - bot.creative.stopFlying() - endFlyLoop?.() + if (newState) { + startFlying(sendAbilities) } else { - // window.flyingSpeed will be removed - bot.physics['airborneAcceleration'] = window.flyingSpeed ?? 0.1 - bot.entity.velocity = new Vec3(0, 0, 0) - bot.creative.startFlying() - startFlyLoop() + endFlying(sendAbilities) } gameAdditionalState.isFlying = isFlying() } // #endregion addEventListener('mousedown', async (e) => { + void pointerLock.requestPointerLock() if (!bot) return // wheel click // todo support ctrl+wheel (+nbt) @@ -344,3 +380,33 @@ addEventListener('mousedown', async (e) => { bot.updateHeldItem() } }) + +window.addEventListener('keydown', (e) => { + if (e.code !== 'Escape') return + if (activeModalStack.length) { + hideCurrentModal(undefined, () => { + if (!activeModalStack.length) { + pointerLock.justHitEscape = true + } + }) + } else if (pointerLock.hasPointerLock) { + document.exitPointerLock?.() + if (options.autoExitFullscreen) { + void document.exitFullscreen() + } + } else { + document.dispatchEvent(new Event('pointerlockchange')) + } +}) + +// #region experimental debug things +window.addEventListener('keydown', (e) => { + if (e.code === 'F11') { + e.preventDefault() + void goFullscreen(true) + } + if (e.code === 'KeyL' && e.altKey) { + console.clear() + } +}) +// #endregion diff --git a/src/dayCycle.ts b/src/dayCycle.ts index d5dae9310..80c0acee6 100644 --- a/src/dayCycle.ts +++ b/src/dayCycle.ts @@ -1,3 +1,5 @@ +import { options } from './optionsStorage' + export default () => { bot.on('time', () => { // 0 morning @@ -6,7 +8,7 @@ export default () => { const night = 17_843 const morningStart = 22_300 const morningEnd = 23_961 - const timeProgress = bot.time.time + const timeProgress = options.dayCycleAndLighting ? bot.time.time : 0 // todo check actual colors const dayColorRainy = { r: 111 / 255, g: 156 / 255, b: 236 / 255 } @@ -20,13 +22,13 @@ export default () => { } else if (timeProgress < night) { const progressNorm = timeProgress - evening const progressMax = night - evening - int = progressNorm / progressMax + int = 1 - progressNorm / progressMax } else if (timeProgress < morningStart) { int = 0 } else if (timeProgress < morningEnd) { const progressNorm = timeProgress - morningStart const progressMax = night - morningEnd - int = 1 - (progressNorm / progressMax) + int = progressNorm / progressMax } // todo need to think wisely how to set these values & also move directional light around! const colorInt = Math.max(int, 0.1) diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 4f26d6385..143af2977 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -18,7 +18,7 @@ export default async () => { if (texturepack) { await updateTexturePackInstalledState() if (resourcePackState.resourcePackInstalled) { - if (!confirm(`You are going to install a new texturepack which would REPLACE a current one: ${await getResourcePackName()} Continue?`)) return + if (!confirm(`You are going to install a new resource pack, which will REPLACE the current one: ${await getResourcePackName()} Continue?`)) return } } else { const menu = document.getElementById('play-screen') diff --git a/src/flyingSquidUtils.ts b/src/flyingSquidUtils.ts index 25c21445e..c5b96a079 100644 --- a/src/flyingSquidUtils.ts +++ b/src/flyingSquidUtils.ts @@ -1,5 +1,6 @@ import * as crypto from 'crypto' import UUID from 'uuid-1345' +import { fsState } from './loadSave' // https://github.com/PrismarineJS/node-minecraft-protocol/blob/cf1f67117d586b5e6e21f0d9602da12e9fcf46b6/src/server/login.js#L170 @@ -16,12 +17,13 @@ export function nameToMcOfflineUUID (name) { return (new UUID(javaUUID('OfflinePlayer:' + name))).toString() } +export async function savePlayers () { + await localServer.savePlayersSingleplayer() +} + // todo flying squid should expose save function instead export const saveServer = async () => { - if (!localServer) return + if (!localServer || fsState.isReadonly) return const worlds = [localServer.overworld] as Array - for (const player of localServer.players) { - player.save() - } - await Promise.all(worlds.map(async world => world.saveNow())) + await Promise.all([savePlayers(), ...worlds.map(async world => world.saveNow())]) } diff --git a/src/globalState.ts b/src/globalState.ts index c8f91ea49..605a4e19a 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -5,6 +5,7 @@ import { pointerLock } from './utils' import { options } from './optionsStorage' import type { OptionsGroupType } from './optionsGuiScheme' import { saveServer } from './flyingSquidUtils' +import { fsState } from './loadSave' // todo: refactor structure with support of hideNext=false @@ -119,6 +120,10 @@ export const showContextmenu = (items: ContextMenuItem[], { clientX, clientY }) // --- +type AppConfig = { + mapsProvider?: string +} + export const miscUiState = proxy({ currentDisplayQr: null as string | null, currentTouch: null as boolean | null, @@ -129,6 +134,9 @@ export const miscUiState = proxy({ gameLoaded: false, /** currently trying to load or loaded mc version, after all data is loaded */ loadedDataVersion: null as string | null, + appLoaded: false, + usingGamepadInput: false, + appConfig: null as AppConfig | null }) export const resetStateAfterDisconnect = () => { @@ -138,7 +146,8 @@ export const resetStateAfterDisconnect = () => { miscUiState.flyingSquid = false miscUiState.wanOpened = false miscUiState.currentDisplayQr = null - miscUiState.currentTouch = null + + fsState.saveLoaded = false } export const isGameActive = (foregroundCheck: boolean) => { diff --git a/src/index.ts b/src/index.ts index 57e6930f4..3b3681a44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import './styles.css' import './globals' import 'iconify-icon' import './chat' -import './inventory' +import { onGameLoad } from './playerWindows' import './menus/components/button' import './menus/components/edit_box' @@ -24,20 +24,21 @@ import { initWithRenderer, statsEnd, statsStart } from './topRightStats' import { options, watchValue } from './optionsStorage' import './reactUi.jsx' -import { contro } from './controls' +import { contro, onBotCreate } from './controls' import './dragndrop' -import './browserfs' +import { possiblyCleanHandle } from './browserfs' import './eruda' import { watchOptionsAfterViewerInit } from './watchOptions' import downloadAndOpenFile from './downloadAndOpenFile' +import fs from 'fs' import net from 'net' import mineflayer from 'mineflayer' import { WorldDataEmitter, Viewer } from 'prismarine-viewer/viewer' import pathfinder from 'mineflayer-pathfinder' import { Vec3 } from 'vec3' -import blockInteraction from './blockInteraction' +import worldInteractions from './worldInteractions' import * as THREE from 'three' import { versionsByMinecraftVersion } from 'minecraft-data' @@ -45,35 +46,28 @@ import { versionsByMinecraftVersion } from 'minecraft-data' import { initVR } from './vr' import { activeModalStack, - showModal, - hideCurrentModal, - activeModalStacks, + showModal, activeModalStacks, insertActiveModalStack, isGameActive, - miscUiState, - gameAdditionalState, - resetStateAfterDisconnect, + miscUiState, resetStateAfterDisconnect, notification } from './globalState' import { - pointerLock, - goFullscreen, isCypress, + pointerLock, isCypress, toMajorVersion, setLoadingScreenStatus, setRenderDistance } from './utils' import { - removePanorama, - addPanoramaCubeMap, + removePanorama } from './panorama' import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' import serverOptions from './defaultLocalServerOptions' import dayCycle from './dayCycle' -import { subscribeKey } from 'valtio/utils' import _ from 'lodash-es' import { genTexturePackTextures, watchTexturepackInViewer } from './texturePack' @@ -82,7 +76,11 @@ import CustomChannelClient from './customClient' import debug from 'debug' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import { registerServiceWorker } from './serviceWorker' -import { appStatusState } from './react/AppStatus' +import { appStatusState } from './react/AppStatusProvider' + +import { fsState } from './loadSave' +import { watchFov } from './rendererUtils' +import { loadInMemorySave } from './react/SingleplayerProvider' window.debug = debug window.THREE = THREE @@ -90,6 +88,7 @@ window.THREE = THREE // ACTUAL CODE void registerServiceWorker() +watchFov() // Create three.js context, add to page const renderer = new THREE.WebGLRenderer({ @@ -119,6 +118,8 @@ watchValue(options, (o) => { let postRenderFrameFn = () => { } let delta = 0 let lastTime = performance.now() +let previousWindowWidth = window.innerWidth +let previousWindowHeight = window.innerHeight const renderFrame = (time: DOMHighResTimeStamp) => { if (window.stopLoop) return window.requestAnimationFrame(renderFrame) @@ -133,6 +134,12 @@ const renderFrame = (time: DOMHighResTimeStamp) => { return } } + // ios bug: viewport dimensions are updated after the resize event + if (previousWindowWidth !== window.innerWidth || previousWindowHeight !== window.innerHeight) { + resizeHandler() + previousWindowWidth = window.innerWidth + previousWindowHeight = window.innerHeight + } statsStart() viewer.update() renderer.render(viewer.scene, viewer.camera) @@ -149,15 +156,6 @@ const resizeHandler = () => { viewer.camera.updateProjectionMatrix() renderer.setSize(width, height) } -const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) -addEventListener('resize', (e) => { - if (isIos) { - // ios bug: resize event is fired before deminsion properties are updated - setTimeout(resizeHandler) - } else { - resizeHandler() - } -}) const hud = document.getElementById('hud') const pauseMenu = document.getElementById('pause-screen') @@ -166,9 +164,9 @@ let mouseMovePostHandle = (e) => { } let lastMouseMove: number let debugMenu const updateCursor = () => { - blockInteraction.update() + worldInteractions.update() debugMenu ??= hud.shadowRoot.querySelector('#debug-overlay') - debugMenu.cursorBlock = blockInteraction.cursorBlock + debugMenu.cursorBlock = worldInteractions.cursorBlock } function onCameraMove (e) { if (e.type !== 'touchmove' && !pointerLock.hasPointerLock) return @@ -186,33 +184,34 @@ function onCameraMove (e) { updateCursor() } window.addEventListener('mousemove', onCameraMove, { capture: true }) - +contro.on('stickMovement', ({ stick, vector }) => { + if (!isGameActive(true)) return + if (stick !== 'right') return + let { x, z } = vector + if (Math.abs(x) < 0.18) x = 0 + if (Math.abs(z) < 0.18) z = 0 + onCameraMove({ movementX: x * 10, movementY: z * 10, type: 'touchmove' }) + miscUiState.usingGamepadInput = true +}) function hideCurrentScreens () { activeModalStacks['main-menu'] = [...activeModalStack] insertActiveModalStack('', []) } -async function main () { +const loadSingleplayer = (serverOverrides = {}) => { + void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides }) +} +function listenGlobalEvents () { const menu = document.getElementById('play-screen') menu.addEventListener('connect', e => { const options = e.detail void connect(options) }) - const connectSingleplayer = (serverOverrides = {}) => { - void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides }) - } window.addEventListener('singleplayer', (e) => { //@ts-expect-error - connectSingleplayer(e.detail) + loadSingleplayer(e.detail) }) - const qs = new URLSearchParams(window.location.search) - if (qs.get('singleplayer') === '1') { - // todo - setTimeout(() => { - connectSingleplayer() - }) - } } let listeners = [] @@ -247,10 +246,10 @@ async function connect (connectOptions: { document.getElementById('play-screen').style = 'display: none;' removePanorama() - const singeplayer = connectOptions.singleplayer + const { singleplayer } = connectOptions const p2pMultiplayer = !!connectOptions.peerId - miscUiState.singleplayer = singeplayer - miscUiState.flyingSquid = singeplayer || p2pMultiplayer + miscUiState.singleplayer = singleplayer + miscUiState.flyingSquid = singleplayer || p2pMultiplayer const { renderDistance, maxMultiplayerRenderDistance = renderDistance } = options const server = cleanConnectIp(connectOptions.server, '25565') const proxy = cleanConnectIp(connectOptions.proxy, undefined) @@ -272,13 +271,18 @@ async function connect (connectOptions: { postRenderFrameFn = () => { } if (bot) { bot.end() - // ensure mineflayer plugins receive this even for cleanup + // ensure mineflayer plugins receive this event for cleanup bot.emit('end', '') bot.removeAllListeners() bot._client.removeAllListeners() bot._client = undefined window.bot = bot = undefined } + if (singleplayer && !fsState.inMemorySave) { + possiblyCleanHandle(() => { + // todo: this is not enough, we need to wait for all async operations to finish + }) + } resetStateAfterDisconnect() removeAllListeners() } @@ -296,7 +300,7 @@ async function connect (connectOptions: { }, { signal: controller.signal }) // #endregion - setLoadingScreenStatus(`Error encountered. Error message: ${err}`, true) + setLoadingScreenStatus(`Error encountered. ${err}`, true) destroyAll() if (isCypress()) throw err } @@ -343,12 +347,12 @@ async function connect (connectOptions: { viewer.setVersion(version) } - const downloadVersion = connectOptions.botVersion || (singeplayer ? serverOptions.version : undefined) + const downloadVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) if (downloadVersion) { await downloadMcData(downloadVersion) } - if (singeplayer) { + if (singleplayer) { // SINGLEPLAYER EXPLAINER: // Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer) // Note 2: custom Server class is used which simplifies communication & Client creation on it's side @@ -374,14 +378,13 @@ async function connect (connectOptions: { localServer.on('newPlayer', (player) => { // it's you! player.on('loadingStatus', (newStatus) => { - console.log('loadingStatus') setLoadingScreenStatus(newStatus, false, false, true) }) }) } let initialLoadingText: string - if (singeplayer) { + if (singleplayer) { initialLoadingText = 'Local server is still starting' } else if (p2pMultiplayer) { initialLoadingText = 'Connecting to peer' @@ -396,10 +399,10 @@ async function connect (connectOptions: { ...p2pMultiplayer ? { stream: await connectToPeer(connectOptions.peerId), } : {}, - ...singeplayer || p2pMultiplayer ? { + ...singleplayer || p2pMultiplayer ? { keepAlive: false, } : {}, - ...singeplayer ? { + ...singleplayer ? { version: serverOptions.version, connect () { }, Client: CustomChannelClient as any, @@ -422,7 +425,7 @@ async function connect (connectOptions: { } }) as unknown as typeof __type_bot window.bot = bot - if (singeplayer || p2pMultiplayer) { + if (singleplayer || p2pMultiplayer) { // in case of p2pMultiplayer there is still flying-squid on the host side const _supportFeature = bot.supportFeature bot.supportFeature = (feature) => { @@ -489,6 +492,8 @@ async function connect (connectOptions: { if (isCypress()) throw new Error(`disconnected: ${endReason}`) }) + onBotCreate() + bot.once('login', () => { // server is ok, add it to the history const serverHistory: string[] = JSON.parse(localStorage.getItem('serverHistory') || '[]') @@ -500,6 +505,7 @@ async function connect (connectOptions: { // don't use spawn event, player can be dead bot.once('health', () => { + miscUiState.gameLoaded = true if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) const mcData = require('minecraft-data')(bot.version) @@ -511,30 +517,9 @@ async function connect (connectOptions: { const center = bot.entity.position - const worldView = window.worldView = new WorldDataEmitter(bot.world, singeplayer ? renderDistance : Math.min(renderDistance, maxMultiplayerRenderDistance), center) + const worldView = window.worldView = new WorldDataEmitter(bot.world, singleplayer ? renderDistance : Math.min(renderDistance, maxMultiplayerRenderDistance), center) setRenderDistance() - const updateFov = () => { - let fovSetting = options.fov - // todo check values and add transition - if (bot.controlState.sprint && !bot.controlState.sneak) { - fovSetting += 5 - } - if (gameAdditionalState.isFlying) { - fovSetting += 5 - } - viewer.camera.fov = fovSetting - viewer.camera.updateProjectionMatrix() - } - updateFov() - subscribeKey(options, 'fov', updateFov) - subscribeKey(gameAdditionalState, 'isFlying', updateFov) - subscribeKey(gameAdditionalState, 'isSprinting', updateFov) - subscribeKey(gameAdditionalState, 'isSneaking', () => { - viewer.isSneaking = gameAdditionalState.isSneaking - viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch) - }) - bot.on('physicsTick', () => updateCursor()) const debugMenu = hud.shadowRoot.querySelector('#debug-overlay') @@ -593,22 +578,6 @@ async function connect (connectOptions: { registerListener(document, 'pointerlockchange', changeCallback, false) - let holdingTouch: { touch: Touch, elem: HTMLElement } | undefined - document.body.addEventListener('touchend', (e) => { - if (!isGameActive(true)) return - if (holdingTouch?.touch.identifier !== e.changedTouches[0].identifier) return - holdingTouch.elem.click() - holdingTouch = undefined - }) - document.body.addEventListener('touchstart', (e) => { - if (!isGameActive(true)) return - e.preventDefault() - holdingTouch = { - touch: e.touches[0], - elem: e.composedPath()[0] as HTMLElement - } - }, { passive: false }) - const cameraControlEl = hud /** after what time of holding the finger start breaking the block */ @@ -663,14 +632,6 @@ async function connect (connectOptions: { capturedPointer.y = e.pageY }, { passive: false }) - contro.on('stickMovement', ({ stick, vector }) => { - if (stick !== 'right') return - let { x, z } = vector - if (Math.abs(x) < 0.18) x = 0 - if (Math.abs(z) < 0.18) z = 0 - onCameraMove({ movementX: x * 10, movementY: z * 10, type: 'touchmove' }) - }) - const pointerUpHandler = (e: PointerEvent) => { if (e.pointerId === undefined || e.pointerId !== capturedPointer?.id) return clearTimeout(virtualClickTimeout) @@ -682,7 +643,7 @@ async function connect (connectOptions: { virtualClickActive = false } else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) { document.dispatchEvent(new MouseEvent('mousedown', { button: 2 })) - blockInteraction.update() + worldInteractions.update() document.dispatchEvent(new MouseEvent('mouseup', { button: 2 })) } capturedPointer = undefined @@ -700,14 +661,20 @@ async function connect (connectOptions: { console.log('Done!') - hud.init(renderer, bot, server.host) - hud.style.display = 'block' - blockInteraction.init() + onGameLoad(async () => { + if (!viewer.world.downloadedBlockStatesData && !viewer.world.customBlockStatesData) { + await new Promise(resolve => { + viewer.world.renderUpdateEmitter.once('blockStatesDownloaded', () => resolve()) + }) + } + hud.init(renderer, bot, server.host) + hud.style.display = 'block' + }) + worldInteractions.init() errorAbortController.abort() if (appStatusState.isError) return setLoadingScreenStatus(undefined) - miscUiState.gameLoaded = true void viewer.waitForChunksToRender().then(() => { console.log('All done and ready!') document.dispatchEvent(new Event('cypress-world-ready')) @@ -715,40 +682,46 @@ async function connect (connectOptions: { }) } -window.addEventListener('mousedown', () => { - void pointerLock.requestPointerLock() -}) - -window.addEventListener('keydown', (e) => { - if (e.code !== 'Escape') return - if (activeModalStack.length) { - hideCurrentModal(undefined, () => { - if (!activeModalStack.length) { - pointerLock.justHitEscape = true +listenGlobalEvents() +watchValue(miscUiState, async s => { + if (s.appLoaded) { // fs ready + const qs = new URLSearchParams(window.location.search) + if (qs.get('singleplayer') === '1') { + loadSingleplayer({ + worldFolder: undefined + }) + } + if (qs.get('loadSave')) { + const savePath = `/data/worlds/${qs.get('loadSave')}` + try { + await fs.promises.stat(savePath) + } catch (err) { + alert(`Save ${savePath} not found`) + return } - }) - } else if (pointerLock.hasPointerLock) { - document.exitPointerLock?.() - if (options.autoExitFullscreen) { - void document.exitFullscreen() + await loadInMemorySave(savePath) } - } else { - document.dispatchEvent(new Event('pointerlockchange')) } }) -window.addEventListener('keydown', (e) => { - if (e.code === 'F11') { - e.preventDefault() - void goFullscreen(true) - } - if (e.code === 'KeyL' && e.altKey) { - console.clear() - } +// #region fire click event on touch as we disable default behaviors +let activeTouch: { touch: Touch, elem: HTMLElement } | undefined +document.body.addEventListener('touchend', (e) => { + if (!isGameActive(true)) return + if (activeTouch?.touch.identifier !== e.changedTouches[0].identifier) return + activeTouch.elem.click() + activeTouch = undefined }) +document.body.addEventListener('touchstart', (e) => { + if (!isGameActive(true)) return + e.preventDefault() + activeTouch = { + touch: e.touches[0], + elem: e.composedPath()[0] as HTMLElement + } +}, { passive: false }) +// #endregion -void addPanoramaCubeMap() -void main() downloadAndOpenFile().then((downloadAction) => { if (downloadAction) return diff --git a/src/inventory.ts b/src/inventory.ts deleted file mode 100644 index 72b712190..000000000 --- a/src/inventory.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { subscribe } from 'valtio' -import { showInventory } from 'minecraft-inventory-gui/web/ext.mjs' -import InventoryGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/inventory.png' -import Dirt from 'minecraft-assets/minecraft-assets/data/1.17.1/blocks/dirt.png' -import { subscribeKey } from 'valtio/utils' -import MinecraftData from 'minecraft-data' -import { getVersion } from 'prismarine-viewer/viewer/lib/version' -import invspriteJson from './invsprite.json' -import { activeModalStack, hideCurrentModal, miscUiState } from './globalState' - -const loadedImages = new Map() -export type BlockStates = Record> -}> - -let blockStates: BlockStates -let lastInventory -let mcData -let version - -subscribeKey(miscUiState, 'gameLoaded', async () => { - if (!miscUiState.gameLoaded) { - // loadedBlocksAtlas = null - return - } - - // on game load - version = getVersion(bot.version) - blockStates = await fetch(`blocksStates/${version}.json`).then(async res => res.json()) - getImage({ path: 'blocks' } as any) - getImage({ path: 'invsprite' } as any) - mcData = MinecraftData(version) -}) - -const findBlockStateTexturesAtlas = (name) => { - const vars = blockStates[name]?.variants - if (!vars) return - const firstVar = Object.values(vars)[0] - if (!firstVar || !Array.isArray(firstVar)) return - return firstVar[0]?.model.textures -} - -const getBlockData = (name) => { - const blocksImg = loadedImages.get('blocks') - if (!blocksImg?.width) return - - const data = findBlockStateTexturesAtlas(name) - if (!data) return - - const getSpriteBlockSide = (side) => { - const d = data[side] - if (!d) return - const spriteSide = [d.u * blocksImg.width, d.v * blocksImg.height, d.su * blocksImg.width, d.sv * blocksImg.height] - const blockSideData = { - slice: spriteSide, - path: 'blocks' - } - return blockSideData - } - - return { - // todo look at grass bug - top: getSpriteBlockSide('up') || getSpriteBlockSide('top'), - left: getSpriteBlockSide('east') || getSpriteBlockSide('side'), - right: getSpriteBlockSide('north') || getSpriteBlockSide('side'), - } -} - -const getItemSlice = (name) => { - const invspriteImg = loadedImages.get('invsprite') - if (!invspriteImg?.width) return - - const { x, y } = invspriteJson[name] ?? /* unknown item */ { x: 0, y: 0 } - const sprite = [x, y, 32, 32] - return sprite -} - -const getImageSrc = (path) => { - switch (path) { - case 'gui/container/inventory': return InventoryGui - case 'blocks': return globalThis.texturePackDataUrl || `textures/${version}.png` - case 'invsprite': return `invsprite.png` - } - return Dirt -} - -const getImage = ({ path, texture, blockData }) => { - const loadPath = blockData ? 'blocks' : path ?? texture - if (!loadedImages.has(loadPath)) { - const image = new Image() - // image.onload(() => {}) - image.src = getImageSrc(loadPath) - loadedImages.set(loadPath, image) - } - return loadedImages.get(loadPath) -} - -const upInventory = () => { - // inv.pwindow.inv.slots[2].displayName = 'test' - // inv.pwindow.inv.slots[2].blockData = getBlockData('dirt') - const customSlots = bot.inventory.slots.map(slot => { - if (!slot) return - // const itemName = slot.name - // const isItem = mcData.itemsByName[itemName] - - // try get block data first, but ideally we should also have atlas from atlas/ folder - const blockData = getBlockData(slot.name) - if (blockData) { - slot['texture'] = 'blocks' - slot['blockData'] = blockData - } else { - slot['texture'] = 'invsprite' - slot['scale'] = 0.5 - slot['slice'] = getItemSlice(slot.name) - } - - return slot - }) - lastInventory.pwindow.setSlots(customSlots) -} - -subscribe(activeModalStack, () => { - const inventoryOpened = activeModalStack.at(-1)?.reactType === 'inventory' - if (inventoryOpened) { - const inv = showInventory(undefined, getImage, {}, bot) - inv.canvas.style.zIndex = '10' - inv.canvas.style.position = 'fixed' - inv.canvas.style.inset = '0' - // todo scaling - inv.canvasManager.setScale(window.innerHeight < 480 ? 2 : window.innerHeight < 700 ? 3 : 4) - inv.canvasManager.onClose = () => { - hideCurrentModal() - inv.canvasManager.destroy() - } - - lastInventory = inv - upInventory() - } else if (lastInventory) { - lastInventory.destroy() - lastInventory = null - } -}) diff --git a/src/loadSave.ts b/src/loadSave.ts index c1149fbde..a8515ec8c 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -1,5 +1,4 @@ import fs from 'fs' -import { promisify } from 'util' import { supportedVersions } from 'flying-squid/src/lib/version' import * as nbt from 'prismarine-nbt' import { proxy } from 'valtio' @@ -7,10 +6,11 @@ import { gzip } from 'node-gzip' import { options } from './optionsStorage' import { nameToMcOfflineUUID } from './flyingSquidUtils' import { forceCachedDataPaths } from './browserfs' -import { isMajorVersionGreater } from './utils' - -const parseNbt = promisify(nbt.parse) +import { disconnect, isMajorVersionGreater } from './utils' +import { activeModalStack, activeModalStacks, hideModal, insertActiveModalStack, miscUiState } from './globalState' +import { appStatusState } from './react/AppStatusProvider' +// todo include name of opened handle (zip)! // additional fs metadata export const fsState = proxy({ isReadonly: false, @@ -21,6 +21,27 @@ export const fsState = proxy({ const PROPOSE_BACKUP = true +export function longArrayToNumber (longArray: number[]) { + const [high, low] = longArray + return (high << 32) + low +} + +export const readLevelDat = async (path) => { + let levelDatContent + try { + // todo-low cache reading + levelDatContent = await fs.promises.readFile(`${path}/level.dat`) + } catch (err) { + if (err.code === 'ENOENT') { + return undefined + } + throw err + } + const { parsed } = await nbt.parse(Buffer.from(levelDatContent)) + const levelDat: import('./mcTypes').LevelDat = nbt.simplify(parsed).Data + return { levelDat, dataRaw: parsed.value.Data.value as Record } +} + export const loadSave = async (root = '/world') => { const disablePrompts = options.disableLoadPrompts @@ -30,30 +51,21 @@ export const loadSave = async (root = '/world') => { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete forceCachedDataPaths[key] } + // todo check jsHeapSizeLimit const warnings: string[] = [] - let levelDatContent - try { - // todo-low cache reading - levelDatContent = await fs.promises.readFile(`${root}/level.dat`) - } catch (err) { - if (err.code === 'ENOENT') { - if (fsState.isReadonly) { - throw new Error('level.dat not found, ensure you are loading world folder') - } else { - warnings.push('level.dat not found, world in current folder will be created') - } + const { levelDat, dataRaw } = await readLevelDat(root) + if (levelDat === undefined) { + if (fsState.isReadonly) { + throw new Error('level.dat not found, ensure you are loading world folder') } else { - throw err + warnings.push('level.dat not found, world in current folder will be created') } } let version: string | undefined let isFlat = false - if (levelDatContent) { - const parsedRaw = await parseNbt(Buffer.from(levelDatContent)) - const levelDat: import('./mcTypes').LevelDat = nbt.simplify(parsedRaw).Data - + if (levelDat) { const qs = new URLSearchParams(window.location.search) version = qs.get('mapVersion') ?? levelDat.Version?.Name if (!version) { @@ -87,17 +99,15 @@ export const loadSave = async (root = '/world') => { const playerUuid = nameToMcOfflineUUID(options.localUsername) const playerDatPath = `${root}/playerdata/${playerUuid}.dat` - try { - await fs.promises.stat(playerDatPath) - } catch (err) { - const playerDat = await gzip(nbt.writeUncompressed({ name: '', ...(parsedRaw.value.Data.value as Record).Player })) + const playerDataOverride = dataRaw.Player + if (playerDataOverride) { + const playerDat = await gzip(nbt.writeUncompressed({ name: '', ...playerDataOverride })) if (fsState.isReadonly) { forceCachedDataPaths[playerDatPath] = playerDat } else { await fs.promises.writeFile(playerDatPath, playerDat) } } - } if (warnings.length && !disablePrompts) { @@ -106,8 +116,6 @@ export const loadSave = async (root = '/world') => { } if (PROPOSE_BACKUP) { - // TODO-HIGH! enable after copyFile in browserfs is implemented - // const doBackup = options.alwaysBackupWorldBeforeLoading ?? confirm('Do you want to backup your world files before loading it?') // // const doBackup = true // if (doBackup) { @@ -123,10 +131,27 @@ export const loadSave = async (root = '/world') => { // } } - if (!fsState.isReadonly) { + if (!fsState.isReadonly && !fsState.inMemorySave && !disablePrompts) { // todo allow also to ctrl+s - alert('Note: the world is saved only on /save or disconnect! ENSURE YOU HAVE BACKUP!') + alert('Note: the world is saved only on /save or disconnect! Ensure you have backup!') + } + + // todo fix these + if (miscUiState.gameLoaded) { + await disconnect() + } + // todo reimplement + if (activeModalStacks['main-menu']) { + insertActiveModalStack('main-menu') } + // todo use general logic + // if (activeModalStack.at(-1)?.reactType === 'app-status' && !appStatusState.isError) { + // alert('Wait for operations to finish before loading a new world') + // return + // } + // for (const _i of activeModalStack) { + // hideModal(undefined, undefined, { force: true }) + // } fsState.saveLoaded = true window.dispatchEvent(new CustomEvent('singleplayer', { diff --git a/src/menus/components/hotbar.js b/src/menus/components/hotbar.js index ee30a8270..8a67444d4 100644 --- a/src/menus/components/hotbar.js +++ b/src/menus/components/hotbar.js @@ -4,6 +4,7 @@ const { subscribeKey } = require('valtio/utils') const invsprite = require('../../invsprite.json') const { isGameActive, miscUiState, showModal } = require('../../globalState') +const { openPlayerInventory, renderSlotExternal } = require('../../playerWindows') const { isProbablyIphone } = require('./common') class Hotbar extends LitElement { @@ -57,7 +58,6 @@ class Hotbar extends LitElement { height: 32px; transform-origin: top left; transform: scale(0.5); - background-image: url('invsprite.png'); background-size: 1024px auto; } @@ -103,8 +103,6 @@ class Hotbar extends LitElement { static get properties () { return { activeItemName: { type: String }, - bot: { type: Object }, - viewerVersion: { type: String } } } @@ -114,25 +112,11 @@ class Hotbar extends LitElement { this.requestUpdate() }) this.activeItemName = '' - } - - updated (changedProperties) { - if (changedProperties.has('bot')) { - // inventory listener - this.bot.once('spawn', () => { - this.init() - }) - } - } - - init () { - this.reloadHotbar() - this.reloadHotbarSelected(0) document.addEventListener('wheel', (e) => { if (!isGameActive(true)) return e.preventDefault() - const newSlot = ((this.bot.quickBarSlot + Math.sign(e.deltaY)) % 9 + 9) % 9 + const newSlot = ((bot.quickBarSlot + Math.sign(e.deltaY)) % 9 + 9) % 9 this.reloadHotbarSelected(newSlot) }, { passive: false, @@ -144,39 +128,45 @@ class Hotbar extends LitElement { if (numPressed < 1 || numPressed > 9) return this.reloadHotbarSelected(numPressed - 1) }) + } + + init () { + this.reloadHotbar() + this.reloadHotbarSelected(0) - this.bot.inventory.on('updateSlot', (slot, oldItem, newItem) => { - if (slot >= this.bot.inventory.hotbarStart + 9) return - if (slot < this.bot.inventory.hotbarStart) return + bot.inventory.on('updateSlot', (slot, oldItem, newItem) => { + if (slot >= bot.inventory.hotbarStart + 9) return + if (slot < bot.inventory.hotbarStart) return - const sprite = newItem ? invsprite[newItem.name] ?? { x: 0, y: 0 } : invsprite.air - const slotEl = this.shadowRoot.getElementById('hotbar-' + (slot - this.bot.inventory.hotbarStart)) - const slotIcon = slotEl.children[0] - const slotStack = slotEl.children[1] - slotIcon.style['background-position-x'] = `-${sprite.x}px` - slotIcon.style['background-position-y'] = `-${sprite.y}px` - slotStack.textContent = newItem?.count > 1 ? newItem.count : '' + this.reloadHotbar(slot - bot.inventory.hotbarStart) }) } - async reloadHotbar () { + reloadHotbar (onlySlot = undefined) { for (let i = 0; i < 9; i++) { - const item = this.bot.inventory.slots[this.bot.inventory.hotbarStart + i] - const sprite = item ? invsprite[item.name] ?? { x: 0, y: 0 } : invsprite.air + if (onlySlot !== undefined && onlySlot !== i) continue + const item = bot.inventory.slots[bot.inventory.hotbarStart + i] const slotEl = this.shadowRoot.getElementById('hotbar-' + i) const slotIcon = slotEl.children[0] const slotStack = slotEl.children[1] - slotIcon.style['background-position-x'] = `-${sprite.x}px` - slotIcon.style['background-position-y'] = `-${sprite.y}px` + const data = item ? renderSlotExternal(item) : { sprite: [invsprite.air.x, invsprite.air.y] } + if (data?.imageDataUrl) { + slotIcon.style['background-image'] = `url('${data.imageDataUrl}')` + } else { + slotIcon.style['background-image'] = `url('invsprite.png')` + } + const [x, y] = data?.sprite ?? [0, 0] + slotIcon.style['background-position-x'] = `-${x}px` + slotIcon.style['background-position-y'] = `-${y}px` slotStack.textContent = item?.count > 1 ? item.count : '' } } async reloadHotbarSelected (slot) { - const item = this.bot.inventory.slots[this.bot.inventory.hotbarStart + slot] + const item = bot.inventory.slots[bot.inventory.hotbarStart + slot] const newLeftPos = (-1 + 20 * slot) + 'px' this.shadowRoot.getElementById('hotbar-selected').style.left = newLeftPos - this.bot.setQuickBarSlot(slot) + bot.setQuickBarSlot(slot) this.activeItemName = item?.displayName ?? '' const name = this.shadowRoot.getElementById('hotbar-item-name') name.classList.remove('hotbar-item-name-fader') @@ -202,7 +192,7 @@ class Hotbar extends LitElement { `)} ${miscUiState.currentTouch ? html`
{ - showModal({ reactType: 'inventory' }) + openPlayerInventory() }}>` : undefined}
diff --git a/src/menus/hud.js b/src/menus/hud.js index 97f98c069..79781abda 100644 --- a/src/menus/hud.js +++ b/src/menus/hud.js @@ -9,11 +9,6 @@ export const guiIcons1_17_1 = require('minecraft-assets/minecraft-assets/data/1. export const guiIcons1_16_4 = require('minecraft-assets/minecraft-assets/data/1.16.4/gui/icons.png') class Hud extends LitElement { - firstUpdated () { - this.isReady = true - window.dispatchEvent(new CustomEvent('hud-ready', { detail: this })) - } - static get styles () { return css` :host { @@ -111,6 +106,21 @@ class Hud extends LitElement { } } + firstUpdated () { + this.isReady = true + window.dispatchEvent(new CustomEvent('hud-ready', { detail: this })) + + watchValue(options, (o) => { + miscUiState.currentTouch = o.alwaysShowMobileControls || isMobile() + this.showMobileControls(miscUiState.currentTouch) + }) + + watchValue(miscUiState, o => { + //@ts-expect-error + this.shadowRoot.host.style.display = o.gameLoaded ? 'block' : 'none' + }) + } + /** * @param {import('mineflayer').Bot} bot */ @@ -137,7 +147,6 @@ class Hud extends LitElement { const xpLabel = this.shadowRoot.querySelector('#xp-label') this.bot = bot - hotbar.bot = bot debugMenu.bot = bot hotbar.init() @@ -192,11 +201,6 @@ class Hud extends LitElement { // TODO // breathbar.updateOxygen(bot.oxygenLevel ?? 20) - - watchValue(options, (o) => { - miscUiState.currentTouch = o.alwaysShowMobileControls || isMobile() - this.showMobileControls(miscUiState.currentTouch) - }) } /** @param {boolean} bl */ diff --git a/src/menus/pause_screen.js b/src/menus/pause_screen.js index 9356aaa1e..1d056cedf 100644 --- a/src/menus/pause_screen.js +++ b/src/menus/pause_screen.js @@ -6,6 +6,8 @@ const { hideCurrentModal, showModal, miscUiState, notification, openOptionsMenu const { fsState } = require('../loadSave') const { disconnect } = require('../utils') const { closeWan, openToWanAndCopyJoinLink, getJoinLink } = require('../localServerMultiplayer') +const { uniqueFileNameFromWorldName, copyFilesAsyncWithProgress } = require('../browserfs') +const { showOptionsModal } = require('../react/SelectOption') const { openURL } = require('./components/common') class PauseScreen extends LitElement { @@ -37,7 +39,7 @@ class PauseScreen extends LitElement { position: absolute; left: 50%; width: 204px; - top: calc(25% + 48px - 16px); + top: calc(48px); transform: translate(-50%); } @@ -60,12 +62,26 @@ class PauseScreen extends LitElement { subscribeKey(miscUiState, 'wanOpened', () => this.requestUpdate()) } + async openWorldActions () { + if (fsState.inMemorySave || !miscUiState.singleplayer) { + return showOptionsModal('World actions...', []) + } + const action = await showOptionsModal('World actions...', ['Save to browser memory']) + if (action === 'Save to browser memory') { + const { worldFolder } = localServer.options + const savePath = await uniqueFileNameFromWorldName(worldFolder.split('/').pop(), `/data/worlds`) + await copyFilesAsyncWithProgress(worldFolder, savePath) + } + } + render () { const joinButton = miscUiState.singleplayer const isOpenedToWan = miscUiState.wanOpened return html`
+ +

Game Menu

diff --git a/src/menus/play_screen.js b/src/menus/play_screen.js index a761b26da..45ef98a90 100644 --- a/src/menus/play_screen.js +++ b/src/menus/play_screen.js @@ -3,7 +3,7 @@ const { LitElement, html, css } = require('lit') const mineflayer = require('mineflayer') const viewerSupportedVersions = require('prismarine-viewer/viewer/supportedVersions.json') const { versionsByMinecraftVersion } = require('minecraft-data') -const { hideCurrentModal } = require('../globalState') +const { hideCurrentModal, miscUiState } = require('../globalState') const { commonCss } = require('./components/common') const fullySupporedVersions = viewerSupportedVersions @@ -91,6 +91,7 @@ class PlayScreen extends LitElement { console.error('Failed to load config.json', error) return {} }).then(config => { + miscUiState.appConfig = config const params = new URLSearchParams(window.location.search) const getParam = (localStorageKey, qs = localStorageKey) => { diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 889c4d850..1473df4fb 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -9,6 +9,7 @@ import Slider from './react/Slider' import { getScreenRefreshRate, openFilePicker, setLoadingScreenStatus } from './utils' import { getResourcePackName, resourcePackState, uninstallTexturePack } from './texturePack' import { fsState } from './loadSave' +import { resetLocalStorageWithoutWorld } from './browserfs' export const guiOptionsScheme: { [t in OptionsGroupType]: Array<{ [k in keyof AppOptions]?: Partial } & { custom?}> @@ -32,6 +33,7 @@ export const guiOptionsScheme: { }, { highPerformanceGpu: { + // todo reimplement to gpu preference to allow use low-energy instead text: 'Use Dedicated GPU', // willHaveNoEffect: isIos }, @@ -40,7 +42,19 @@ export const guiOptionsScheme: { custom () { return + }, + } ], } export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 3681d2724..d7354a060 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -26,6 +26,10 @@ const defaultOptions = { autoRequestCompletions: true, touchButtonsSize: 40, highPerformanceGpu: false, + /** @unstable */ + disableAssets: false, + unimplementedContainers: false, + dayCycleAndLighting: true, showChunkBorders: false, frameLimit: false as number | false, @@ -53,6 +57,10 @@ export const options = proxy( window.options = window.settings = options +export const resetOptions = () => { + Object.assign(options, defaultOptions) +} + subscribe(options, () => { localStorage.options = JSON.stringify(options) }) @@ -78,6 +86,10 @@ watchValue(options, o => { globalThis.excludeCommunicationDebugEvents = o.excludeCommunicationDebugEvents }) +watchValue(options, o => { + document.body.classList.toggle('disable-assets', o.disableAssets) +}) + export const useOptionValue = (setting, valueCallback) => { valueCallback(setting) subscribe(setting, valueCallback) diff --git a/src/panorama.js b/src/panorama.js index a7395baa9..aa540b1a2 100644 --- a/src/panorama.js +++ b/src/panorama.js @@ -4,12 +4,12 @@ import { join } from 'path' import fs from 'fs' import { subscribeKey } from 'valtio/utils' import { fromTexturePackPath, resourcePackState } from './texturePack' -import { options } from './optionsStorage' +import { options, watchValue } from './optionsStorage' import { miscUiState } from './globalState' let panoramaCubeMap let shouldDisplayPanorama = false -let panoramaUsesResourcePack = false +let panoramaUsesResourcePack = null const panoramaFiles = [ 'panorama_1.png', // WS @@ -43,17 +43,28 @@ const updateResourcePackSupportPanorama = async () => { } } -subscribeKey(resourcePackState, 'resourcePackInstalled', async () => { - const oldState = panoramaUsesResourcePack - const newState = resourcePackState.resourcePackInstalled && (await updateResourcePackSupportPanorama(), panoramaUsesResourcePack) - if (newState === oldState) return - removePanorama() - void addPanoramaCubeMap() +watchValue(miscUiState, m => { + if (m.appLoaded) { + // Also adds panorama on app load here + watchValue(resourcePackState, async (s) => { + const oldState = panoramaUsesResourcePack + const newState = s.resourcePackInstalled && (await updateResourcePackSupportPanorama(), panoramaUsesResourcePack) + if (newState === oldState) return + removePanorama() + void addPanoramaCubeMap() + }) + } +}) + +subscribeKey(miscUiState, 'loadedDataVersion', () => { + if (miscUiState.loadedDataVersion) removePanorama() + else void addPanoramaCubeMap() }) // Menu panorama background +// TODO-low use abort controller export async function addPanoramaCubeMap () { - if (panoramaCubeMap || miscUiState.loadedDataVersion) return + if (panoramaCubeMap || miscUiState.loadedDataVersion || options.disableAssets) return shouldDisplayPanorama = true let time = 0 diff --git a/src/playerWindows.ts b/src/playerWindows.ts new file mode 100644 index 000000000..c228d2914 --- /dev/null +++ b/src/playerWindows.ts @@ -0,0 +1,334 @@ +import { subscribe } from 'valtio' +import { showInventory } from 'minecraft-inventory-gui/web/ext.mjs' +import InventoryGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/inventory.png' +import ChestLikeGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/shulker_box.png' +import LargeChestLikeGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/generic_54.png' +import FurnaceGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/furnace.png' +import CraftingTableGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/crafting_table.png' +import DispenserGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/container/dispenser.png' + +import Dirt from 'minecraft-assets/minecraft-assets/data/1.17.1/blocks/dirt.png' +import { subscribeKey } from 'valtio/utils' +import MinecraftData from 'minecraft-data' +import { getVersion } from 'prismarine-viewer/viewer/lib/version' +import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' +import itemsPng from 'prismarine-viewer/public/textures/items.png' +import itemsLegacyPng from 'prismarine-viewer/public/textures/items-legacy.png' +import _itemsAtlases from 'prismarine-viewer/public/textures/items.json' +import type { ItemsAtlasesOutputJson } from 'prismarine-viewer/viewer/prepare/genItemsAtlas' +import PrismarineBlockLoader from 'prismarine-block' +import { activeModalStack, hideCurrentModal, miscUiState, showModal } from './globalState' +import invspriteJson from './invsprite.json' +import { options } from './optionsStorage' + +const itemsAtlases: ItemsAtlasesOutputJson = _itemsAtlases +const loadedImagesCache = new Map() +const cleanLoadedImagesCache = () => { + loadedImagesCache.delete('blocks') +} +export type BlockStates = Record +}> + +let lastWindow +let version: string +let PrismarineBlock: typeof PrismarineBlockLoader.Block + +export const onGameLoad = (onLoad) => { + let loaded = 0 + const onImageLoaded = () => { + loaded++ + if (loaded === 3) onLoad?.() + } + version = getVersion(bot.version) + getImage({ path: 'invsprite' }, onImageLoaded) + getImage({ path: 'items' }, onImageLoaded) + getImage({ path: 'items-legacy' }, onImageLoaded) + PrismarineBlock = PrismarineBlockLoader(version) + + bot.on('windowOpen', (win) => { + if (implementedContainersGuiMap[win.type]) { + // todo also render title! + openWindow(implementedContainersGuiMap[win.type]) + } else if (options.unimplementedContainers) { + openWindow('ChestWin') + } else { + // todo format + bot._client.emit('chat', { + message: JSON.stringify({ + text: `[client error] cannot open unimplemented window ${win.id} (${win.type}). Items: ${win.slots.map(slot => slot?.name).join(', ')}` + }) + }) + bot.currentWindow['close']() + } + }) +} + +const findTextureInBlockStates = (name) => { + const blockStates: BlockStates = viewer.world.customBlockStatesData || viewer.world.downloadedBlockStatesData + const vars = blockStates[name]?.variants + if (!vars) return + let firstVar = Object.values(vars)[0] + if (Array.isArray(firstVar)) firstVar = firstVar[0] + if (!firstVar) return + const elements = firstVar.model?.elements + if (elements?.length !== 1) return + return elements[0].faces +} + +const svSuToCoordinates = (path: string, u, v, su, sv = su) => { + const img = getImage({ path }) + if (!img.width) throw new Error(`Image ${path} is not loaded`) + return [u * img.width, v * img.height, su * img.width, sv * img.height] +} + +const getBlockData = (name) => { + const data = findTextureInBlockStates(name) + if (!data) return + + const getSpriteBlockSide = (side) => { + const d = data[side]?.texture + if (!d) return + const spriteSide = svSuToCoordinates('blocks', d.u, d.v, d.su, d.sv) + const blockSideData = { + slice: spriteSide, + path: 'blocks' + } + return blockSideData + } + + return { + // todo look at grass bug + top: getSpriteBlockSide('up') || getSpriteBlockSide('top'), + left: getSpriteBlockSide('east') || getSpriteBlockSide('side'), + right: getSpriteBlockSide('north') || getSpriteBlockSide('side'), + } +} + +const getInvspriteSlice = (name) => { + const invspriteImg = loadedImagesCache.get('invsprite') + if (!invspriteImg?.width) return + + const { x, y } = invspriteJson[name] ?? /* unknown item */ { x: 0, y: 0 } + const sprite = [x, y, 32, 32] + return sprite +} + +const getImageSrc = (path): string | HTMLImageElement => { + switch (path) { + case 'gui/container/inventory': return InventoryGui + case 'blocks': return viewer.world.customTexturesDataUrl || viewer.world.downloadedTextureImage + case 'invsprite': return `invsprite.png` + case 'items': return itemsPng + case 'items-legacy': return itemsLegacyPng + case 'gui/container/dispenser': return DispenserGui + case 'gui/container/furnace': return FurnaceGui + case 'gui/container/crafting_table': return CraftingTableGui + case 'gui/container/shulker_box': return ChestLikeGui + case 'gui/container/generic_54': return LargeChestLikeGui + } + return Dirt +} + +const getImage = ({ path = undefined, texture = undefined, blockData = undefined }, onLoad = () => {}) => { + const loadPath = blockData ? 'blocks' : path ?? texture + if (loadedImagesCache.has(loadPath)) { + onLoad() + } else { + const imageSrc = getImageSrc(loadPath) + let image: HTMLImageElement + if (imageSrc instanceof Image) { + image = imageSrc + } else { + image = new Image() + image.src = imageSrc + } + image.onload = onLoad + loadedImagesCache.set(loadPath, image) + } + return loadedImagesCache.get(loadPath) +} + +const getItemVerToRender = (version: string, item: string, itemsMapSortedEntries: any[]) => { + const verNumber = versionToNumber(version) + for (const [itemsVer, items] of itemsMapSortedEntries) { + // 1.18 < 1.18.1 + // 1.13 < 1.13.2 + if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) { + return itemsVer as string + } + } +} + +const isFullBlock = (block: string) => { + const blockData = loadedData.blocksByName[block] + if (!blockData) return false + const pBlock = new PrismarineBlock(blockData.id, 0, 0) + if (pBlock.shapes?.length !== 1) return false + const shape = pBlock.shapes[0]! + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 +} + +const renderSlot = (slot: import('prismarine-item').Item, skipBlock = false): { texture: string, blockData?, scale?: number, slice?: number[] } => { + const itemName = slot.name + const isItem = loadedData.itemsByName[itemName] + const fullBlock = isFullBlock(itemName) + + if (isItem) { + const legacyItemVersion = getItemVerToRender(version, itemName, itemsAtlases.legacyMap) + const vuToSlice = ({ u, v }, size) => [...svSuToCoordinates('items', u, v, size).slice(0, 2), 16, 16] // item size is fixed + if (legacyItemVersion) { + const textureData = itemsAtlases.legacy.textures[`${legacyItemVersion}-${itemName}`]! + return { + texture: 'items-legacy', + slice: vuToSlice(textureData, itemsAtlases.legacy.size) + } + } + const textureData = itemsAtlases.latest.textures[itemName] + if (textureData) { + return { + texture: 'items', + slice: vuToSlice(textureData, itemsAtlases.latest.size) + } + } + } + if (fullBlock && !skipBlock) { + const blockData = getBlockData(itemName) + if (blockData) { + return { + texture: 'blocks', + blockData + } + } + } + const invspriteSlice = getInvspriteSlice(itemName) + if (invspriteSlice) { + return { + texture: 'invsprite', + scale: 0.5, + slice: invspriteSlice + } + } +} + +export const renderSlotExternal = (slot) => { + const data = renderSlot(slot, true) + if (!data) return + return { + imageDataUrl: data.texture === 'invsprite' ? undefined : getImage({ path: data.texture }).src, + sprite: data.slice && data.texture !== 'invsprite' ? data.slice.map(x => x * 2) : data.slice + } +} + +const upInventory = (inventory: boolean) => { + // inv.pwindow.inv.slots[2].displayName = 'test' + // inv.pwindow.inv.slots[2].blockData = getBlockData('dirt') + const updateSlots = (inventory ? bot.inventory : bot.currentWindow).slots.map(slot => { + // todo stateid + if (!slot) return + + try { + const slotCustomProps = renderSlot(slot) + Object.assign(slot, slotCustomProps) + } catch (err) { + console.error(err) + } + return slot + }) + const customSlots = updateSlots + lastWindow.pwindow.setSlots(customSlots) +} + +export const onModalClose = (callback: () => any) => { + const { length } = activeModalStack + const unsubscribe = subscribe(activeModalStack, () => { + if (activeModalStack.length < length) { + callback() + unsubscribe() + } + }) +} + +const implementedContainersGuiMap = { + // todo allow arbitrary size instead! + 'minecraft:generic_9x3': 'ChestWin', + 'minecraft:generic_9x6': 'LargeChestWin', + 'minecraft:generic_3x3': 'DropDispenseWin', + 'minecraft:furnace': 'FurnaceWin', + 'minecraft:smoker': 'FurnaceWin', + 'minecraft:crafting': 'CraftingWin' +} + +const openWindow = (type: string | undefined) => { + // if (activeModalStack.some(x => x.reactType?.includes?.('player_win:'))) { + if (activeModalStack.length) { // game is not in foreground, don't close current modal + if (type) bot.currentWindow['close']() + return + } + showModal({ + reactType: `player_win:${type}`, + }) + onModalClose(() => { + // might be already closed (event fired) + if (type !== undefined && bot.currentWindow) bot.currentWindow['close']() + lastWindow.destroy() + lastWindow = null + destroyFn() + }) + cleanLoadedImagesCache() + const inv = showInventory(type, getImage, {}, bot) + inv.canvas.style.zIndex = '10' + inv.canvas.style.position = 'fixed' + inv.canvas.style.inset = '0' + // todo scaling + inv.canvasManager.setScale(window.innerHeight < 480 ? 2 : window.innerHeight < 700 ? 3 : 4) + + inv.canvasManager.onClose = () => { + hideCurrentModal() + inv.canvasManager.destroy() + } + + lastWindow = inv + const upWindowItems = () => { + upInventory(type === undefined) + } + upWindowItems() + + if (type === undefined) { + // player inventory + bot.inventory.on('updateSlot', upWindowItems) + destroyFn = () => { + bot.inventory.off('updateSlot', upWindowItems) + } + } else { + bot.on('windowClose', () => { + // todo hide up to the window itself! + hideCurrentModal() + }) + //@ts-expect-error + bot.currentWindow.on('updateSlot', () => { + upWindowItems() + }) + } +} + +let destroyFn = () => { } + +export const openPlayerInventory = () => { + openWindow(undefined) +} diff --git a/src/react/AppStatus.tsx b/src/react/AppStatus.tsx index 65ea18410..9384b944b 100644 --- a/src/react/AppStatus.tsx +++ b/src/react/AppStatus.tsx @@ -1,32 +1,11 @@ import { useEffect, useState } from 'react' -import { proxy, useSnapshot } from 'valtio' -import { activeModalStacks, hideModal, insertActiveModalStack, miscUiState } from '../globalState' import { guessProblem } from '../guessProblem' -import { addPanoramaCubeMap } from '../panorama' -import { fsState } from '../loadSave' -import { resetLocalStorageWorld } from '../browserfs' -import styles from './loadingErrorScreen.module.css' +import styles from './appStatus.module.css' import Button from './Button' import Screen from './Screen' -import DiveTransition from './DiveTransition' -import { isModalActive, useDidUpdateEffect } from './utils' -const initialState = { - status: '', - lastStatus: '', - maybeRecoverable: true, - isError: false, - hideDots: false, -} -export const appStatusState = proxy(initialState) -const resetState = () => { - Object.assign(appStatusState, initialState) -} - -export default () => { - const { isError, lastStatus, maybeRecoverable, status, hideDots } = useSnapshot(appStatusState) +export default ({ status, isError, hideDots = false, lastStatus = '', backAction = undefined, actionsSlot = undefined }) => { const [loadingDots, setLoadingDots] = useState('') - const isOpen = isModalActive('app-status') useEffect(() => { void statusRunner() @@ -48,59 +27,25 @@ export default () => { void load() } - useDidUpdateEffect(() => { - // todo play effect only when world successfully loaded - if (!isOpen) { - const divingElem: HTMLElement = document.querySelector('#viewer-canvas') - divingElem.style.animationName = 'dive-animation' - divingElem.parentElement.style.perspective = '1200px' - divingElem.onanimationend = () => { - divingElem.parentElement.style.perspective = '' - divingElem.onanimationend = null - } - } - }, [isOpen]) - return ( - - - {status} - {isError || hideDots ? '' : loadingDots} -

{isError ? guessProblem(status) : ''}

-

{lastStatus ? `Last status: ${lastStatus}` : lastStatus}

- - } - backdrop='dirt' - > - {isError && ( - <> - - - - - )} -
-
+ + {status} + {isError || hideDots ? '' : loadingDots} +

{isError ? guessProblem(status) : ''}

+

{lastStatus ? `Last status: ${lastStatus}` : lastStatus}

+ + } + backdrop='dirt' + > + {isError && ( + <> + {backAction && + + )} +
) } diff --git a/src/react/AppStatusProvider.tsx b/src/react/AppStatusProvider.tsx new file mode 100644 index 000000000..1fa594e86 --- /dev/null +++ b/src/react/AppStatusProvider.tsx @@ -0,0 +1,68 @@ +import { proxy, useSnapshot } from 'valtio' +import { activeModalStacks, hideModal, insertActiveModalStack, miscUiState } from '../globalState' +import { resetLocalStorageWorld } from '../browserfs' +import { fsState } from '../loadSave' +import AppStatus from './AppStatus' +import DiveTransition from './DiveTransition' +import { useDidUpdateEffect, useIsModalActive } from './utils' +import Button from './Button' + +const initialState = { + status: '', + lastStatus: '', + maybeRecoverable: true, + isError: false, + hideDots: false, +} +export const appStatusState = proxy(initialState) +const resetState = () => { + Object.assign(appStatusState, initialState) +} + +export default () => { + const { isError, lastStatus, maybeRecoverable, status, hideDots } = useSnapshot(appStatusState) + + const isOpen = useIsModalActive('app-status') + + useDidUpdateEffect(() => { + // todo play effect only when world successfully loaded + if (!isOpen) { + const divingElem: HTMLElement = document.querySelector('#viewer-canvas') + divingElem.style.animationName = 'dive-animation' + divingElem.parentElement.style.perspective = '1200px' + divingElem.onanimationend = () => { + divingElem.parentElement.style.perspective = '' + divingElem.onanimationend = null + } + } + }, [isOpen]) + + return + { + appStatusState.isError = false + resetState() + miscUiState.gameLoaded = false + miscUiState.loadedDataVersion = undefined + window.loadedData = undefined + if (activeModalStacks['main-menu']) { + insertActiveModalStack('main-menu') + } else { + hideModal(undefined, undefined, { force: true }) + } + } : undefined} + // actionsSlot={ + // + + +
Default and other world types are WIP
+ +
+ + +
+
Note: store important saves in folders on the drive!
+
{quota}
+ +} + +export const WorldCustomize = ({ backClick }) => { + const { type } = useSnapshot(creatingWorldState) + + return +
+
+ +
+
+ +
+} diff --git a/src/react/CreateWorldProvider.tsx b/src/react/CreateWorldProvider.tsx new file mode 100644 index 000000000..900e577f0 --- /dev/null +++ b/src/react/CreateWorldProvider.tsx @@ -0,0 +1,68 @@ +import { supportedVersions } from 'flying-squid/src/lib/version' +import { hideCurrentModal, showModal } from '../globalState' +import defaultLocalServerOptions from '../defaultLocalServerOptions' +import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' +import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld' +import { useIsModalActive } from './utils' + +export default () => { + const activeCreate = useIsModalActive('create-world') + const activeCustomize = useIsModalActive('customize-world') + if (activeCreate) { + const ignoredVersionsRegex = /(^0\.30c$)|w|-pre|-rc/ + const versions = supportedVersions.filter(x => !ignoredVersionsRegex.test(x)).map(x => { + return { + version: x, + label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x + } + }) + return { + hideCurrentModal() + }} + createClick={async () => { + // create new world + const { title, type, version } = creatingWorldState + // todo display path in ui + disable if exist + const savePath = await uniqueFileNameFromWorldName(title, `/data/worlds`) + await mkdirRecursive(savePath) + let generation + if (type === 'flat') { + generation = { + name: 'superflat', + } + } + if (type === 'void') { + generation = { + name: 'superflat', + layers: [], + noDefaults: true + } + } + if (type === 'nether') { + generation = { + name: 'nether' + } + } + hideCurrentModal() + window.dispatchEvent(new CustomEvent('singleplayer', { + detail: { + levelName: title, + version, + generation, + 'worldFolder': savePath + }, + })) + }} + customizeClick={() => { + showModal({ reactType: 'customize-world' }) + }} + versions={versions} + /> + } + if (activeCustomize) { + return hideCurrentModal()} /> + } + return null +} diff --git a/src/react/Input.tsx b/src/react/Input.tsx new file mode 100644 index 000000000..bf2404653 --- /dev/null +++ b/src/react/Input.tsx @@ -0,0 +1,25 @@ +import React, { useEffect, useRef } from 'react' +import styles from './input.module.css' + +interface Props extends React.ComponentProps<'input'> { + autoFocus?: boolean + onEnterPress?: (e) => void +} + +export default ({ autoFocus, onEnterPress, ...inputProps }: Props) => { + const ref = useRef(null!) + + useEffect(() => { + if (onEnterPress) { + ref.current.addEventListener('keydown', (e) => { + if (e.code === 'Enter') onEnterPress(e) + }) + } + if (!autoFocus || matchMedia('(pointer: coarse)').matches) return // Don't make screen keyboard popup on mobile + ref.current.focus() + }, []) + + return
+ +
+} diff --git a/src/react/MainMenu.tsx b/src/react/MainMenu.tsx index 8647f45e6..b630dbd1f 100644 --- a/src/react/MainMenu.tsx +++ b/src/react/MainMenu.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react' +import { openURL } from '../menus/components/common' import styles from './mainMenu.module.css' import Button from './Button' +import ButtonWithTooltip from './ButtonWithTooltip' type Action = (e: React.MouseEvent) => void @@ -11,6 +13,7 @@ interface Props { githubAction?: Action discordAction?: Action openFileAction?: Action + mapsProvider?: string } const refreshApp = async () => { @@ -19,7 +22,9 @@ const refreshApp = async () => { window.location.reload() } -export default ({ connectToServerAction, singleplayerAction, optionsAction, githubAction, discordAction, openFileAction }: Props) => { +const httpsRegex = /^https?:\/\// + +export default ({ connectToServerAction, mapsProvider, singleplayerAction, optionsAction, githubAction, discordAction, openFileAction }: Props) => { const [versionStatus, setVersionStatus] = useState('') const [versionTitle, setVersionTitle] = useState('') @@ -47,26 +52,38 @@ export default ({ connectToServerAction, singleplayerAction, optionsAction, gith
- +
- + -
+
+ + {mapsProvider && + openURL(httpsRegex.test(mapsProvider) ? mapsProvider : 'https://' + mapsProvider)} + />} ) } diff --git a/src/react/MainMenuRenderApp.tsx b/src/react/MainMenuRenderApp.tsx index 43ae90b17..91d39913f 100644 --- a/src/react/MainMenuRenderApp.tsx +++ b/src/react/MainMenuRenderApp.tsx @@ -1,46 +1,60 @@ import fs from 'fs' +import { Transition } from 'react-transition-group' import { useSnapshot } from 'valtio' +import { useEffect } from 'react' import { activeModalStack, miscUiState, openOptionsMenu, showModal } from '../globalState' import { openURL } from '../menus/components/common' -import { fsState } from '../loadSave' -import { options } from '../optionsStorage' -import defaultLocalServerOptions from '../defaultLocalServerOptions' -import { openFilePicker } from '../utils' -import { openWorldDirectory } from '../browserfs' +import { openFilePicker, setLoadingScreenStatus } from '../utils' +import { copyFilesAsync, mkdirRecursive, openWorldDirectory, removeFileRecursiveAsync } from '../browserfs' import MainMenu from './MainMenu' +// todo clean +let disableAnimation = false export default () => { const haveModals = useSnapshot(activeModalStack).length - const { gameLoaded } = useSnapshot(miscUiState) - if (haveModals || gameLoaded) return + const { gameLoaded, appLoaded, appConfig } = useSnapshot(miscUiState) - return showModal(document.getElementById('play-screen'))} - singleplayerAction={() => { - fsState.isReadonly = false - fsState.syncFs = true - fsState.inMemorySave = true - const notFirstTime = fs.existsSync('./world/level.dat') - if (notFirstTime && !options.localServerOptions.version) { - options.localServerOptions.version = '1.16.1' // legacy version - } else { - options.localServerOptions.version ??= defaultLocalServerOptions.version - } - window.dispatchEvent(new window.CustomEvent('singleplayer', { - detail: { - savingInterval: 0 // disable auto-saving because we use very slow sync fs - }, - })) - }} - githubAction={() => openURL(process.env.GITHUB_URL)} - optionsAction={() => openOptionsMenu('main')} - discordAction={() => openURL('https://discord.gg/4Ucm684Fq3')} - openFileAction={e => { - if (!!window.showDirectoryPicker && !e.shiftKey) { - void openWorldDirectory() - } else { - openFilePicker() - } - }} - /> + const noDisplay = haveModals || gameLoaded || !appLoaded + + useEffect(() => { + if (noDisplay && appLoaded) disableAnimation = true + }, [noDisplay]) + + // todo clean, use custom csstransition + return + {(state) =>
+ showModal(document.getElementById('play-screen'))} + singleplayerAction={async () => { + const oldFormatSave = fs.existsSync('./world/level.dat') + if (oldFormatSave) { + setLoadingScreenStatus('Migrating old save, don\'t close the page') + try { + await mkdirRecursive('/data/worlds/local') + await copyFilesAsync('/world/', '/data/worlds/local') + try { + await removeFileRecursiveAsync('/world/') + } catch (err) { + console.warn(err) + } + } finally { + setLoadingScreenStatus(undefined) + } + } + showModal({ reactType: 'singleplayer' }) + }} + githubAction={() => openURL(process.env.GITHUB_URL)} + optionsAction={() => openOptionsMenu('main')} + discordAction={() => openURL('https://discord.gg/4Ucm684Fq3')} + openFileAction={e => { + if (!!window.showDirectoryPicker && !e.shiftKey) { + void openWorldDirectory() + } else { + openFilePicker() + } + }} + mapsProvider={appConfig.mapsProvider} + /> +
} +
} diff --git a/src/react/OptionsRenderApp.tsx b/src/react/OptionsRenderApp.tsx index 8167f820d..b46e924f9 100644 --- a/src/react/OptionsRenderApp.tsx +++ b/src/react/OptionsRenderApp.tsx @@ -1,7 +1,11 @@ import { useSnapshot } from 'valtio' import { activeModalStack, hideCurrentModal } from '../globalState' import { OptionsGroupType } from '../optionsGuiScheme' +import { uniqueFileNameFromWorldName, copyFilesAsyncWithProgress } from '../browserfs' +import { fsState } from '../loadSave' +import Button from './Button' import OptionsGroup from './OptionsGroup' +import { showOptionsModal } from './SelectOption' export default () => { const { reactType } = useSnapshot(activeModalStack).at(-1) ?? {} diff --git a/src/react/SelectOption.tsx b/src/react/SelectOption.tsx new file mode 100644 index 000000000..b74434e19 --- /dev/null +++ b/src/react/SelectOption.tsx @@ -0,0 +1,39 @@ +import { proxy, useSnapshot } from 'valtio' +import { hideCurrentModal, showModal } from '../globalState' +import Screen from './Screen' +import { useIsModalActive } from './utils' +import Button from './Button' + +const state = proxy({ + title: '', + options: [] as string[] +}) + +let resolve +export const showOptionsModal = async (title: string, options: T[]): Promise => { + showModal({ reactType: 'general-select' }) + return new Promise((_resolve) => { + resolve = _resolve + Object.assign(state, { + title, + options + }) + }) +} + +export default () => { + const { title, options } = useSnapshot(state) + const isModalActive = useIsModalActive('general-select') + if (!isModalActive) return + + return + {options.map(option => )} + + +} diff --git a/src/react/Singleplayer.stories.tsx b/src/react/Singleplayer.stories.tsx new file mode 100644 index 000000000..27b5a61da --- /dev/null +++ b/src/react/Singleplayer.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react' +import Singleplayer from './Singleplayer' + +const meta: Meta<{ open }> = { + component: Singleplayer as any, + render ({ open }) { + return ({ + name: 'test' + i, + title: 'Test Save ' + i, + lastPlayed: Date.now() - 600_000, + size: 100_000, + }))} + onWorldAction={() => { }} + onGeneralAction={() => { }} + /> + }, +} + +export default meta +type Story = StoryObj<{ open }> + +export const Primary: Story = { + args: { + }, +} diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx new file mode 100644 index 000000000..1d9d5aea6 --- /dev/null +++ b/src/react/Singleplayer.tsx @@ -0,0 +1,112 @@ +import classNames from 'classnames' +import { useMemo, useRef, useState } from 'react' + +// todo optimize size +import missingWorldPreview from 'minecraft-assets/minecraft-assets/data/1.10/gui/presets/isles.png' +import { filesize } from 'filesize' +import useTypedEventListener from 'use-typed-event-listener' +import { focusable } from 'tabbable' +import styles from './singleplayer.module.css' +import Input from './Input' +import Button from './Button' + +export interface WorldProps { + name: string + title: string + size?: number + lastPlayed?: number + isFocused?: boolean + onFocus?: (name: string) => void + detail?: string + onInteraction?(interaction: 'enter' | 'space') +} +const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction }: WorldProps) => { + const timeRelativeFormatted = useMemo(() => { + if (!lastPlayed) return + const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + const diff = Date.now() - lastPlayed + const minutes = Math.floor(diff / 1000 / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + // const weeks = Math.floor(days / 7) + // const months = Math.floor(days / 30) + if (days > 0) return formatter.format(-days, 'day') + if (hours > 0) return formatter.format(-hours, 'hour') + return formatter.format(-minutes, 'minute') + }, [lastPlayed]) + const sizeFormatted = useMemo(() => { + return filesize(size) + }, [size]) + + return
onFocus?.(name)} onKeyDown={(e) => { + if (e.code === 'Enter' || e.code === 'Space') { + e.preventDefault() + onInteraction?.(e.code === 'Enter' ? 'enter' : 'space') + } + }} onDoubleClick={() => onInteraction('enter')}> + +
+
{title}
+
{timeRelativeFormatted} {detail.slice(-30)}
+
{sizeFormatted}
+
+
+} + +interface Props { + worldData: WorldProps[] + onWorldAction (action: 'load' | 'export' | 'delete' | 'edit', worldName: string): void + onGeneralAction (action: 'cancel' | 'create'): void +} + +export default ({ worldData, onGeneralAction, onWorldAction }: Props) => { + const containerRef = useRef() + const firstButton = useRef() + + useTypedEventListener(window, 'keydown', (e) => { + if (e.code === 'ArrowDown' || e.code === 'ArrowUp') { + e.preventDefault() + const dir = e.code === 'ArrowDown' ? 1 : -1 + const elements = focusable(containerRef.current) + const focusedElemIndex = elements.indexOf(document.activeElement as HTMLElement) + if (focusedElemIndex === -1) return + const nextElem = elements[focusedElemIndex + dir] + nextElem?.focus() + } + }) + + const [search, setSearch] = useState('') + const [focusedWorld, setFocusedWorld] = useState('') + + return
+
+
+
+ Select Saved World + setSearch(value)} /> +
+
+ { + worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => ( + { + if (interaction === 'enter') onWorldAction('load', name) + else if (interaction === 'space') firstButton.current?.focus() + }} detail={detail} /> + )) + } +
+
+
+ + +
+
+ + + + +
+
+
+
+} diff --git a/src/react/SingleplayerProvider.tsx b/src/react/SingleplayerProvider.tsx new file mode 100644 index 000000000..7a9b96361 --- /dev/null +++ b/src/react/SingleplayerProvider.tsx @@ -0,0 +1,104 @@ +import fs from 'fs' +import { proxy, useSnapshot } from 'valtio' +import { useEffect } from 'react' +import { fsState, loadSave, longArrayToNumber, readLevelDat } from '../loadSave' +import { mountExportFolder, removeFileRecursiveAsync } from '../browserfs' +import { hideCurrentModal, showModal } from '../globalState' +import { setLoadingScreenStatus } from '../utils' +import { exportWorld } from '../builtinCommands' +import Singleplayer, { WorldProps } from './Singleplayer' +import { useIsModalActive } from './utils' +import { showOptionsModal } from './SelectOption' + +const worldsProxy: { value: WorldProps[] } = proxy({ value: [] }) + +export const readWorlds = () => { + (async () => { + try { + const worlds = await fs.promises.readdir(`/data/worlds`) + worldsProxy.value = (await Promise.allSettled(worlds.map(async (world) => { + const { levelDat } = await readLevelDat(`/data/worlds/${world}`) + let size = 0 + // todo use whole dir size + for (const region of await fs.promises.readdir(`/data/worlds/${world}/region`)) { + const stat = await fs.promises.stat(`/data/worlds/${world}/region/${region}`) + size += stat.size + } + return { + name: world, + title: levelDat.LevelName, + lastPlayed: levelDat.LastPlayed && longArrayToNumber(levelDat.LastPlayed), + detail: `${levelDat.Version.Name ?? 'unknown version'}, ${world}`, + size, + } satisfies WorldProps + }))).filter(x => { + if (x.status === 'rejected') { + console.warn(x.reason) + return false + } + return true + }).map(x => (x as Extract).value) + } catch (err) { + console.warn(err) + worldsProxy.value = [] + } + })() +} + +export const loadInMemorySave = async (worldPath: string) => { + fsState.saveLoaded = false + fsState.isReadonly = false + fsState.syncFs = false + fsState.inMemorySave = true + await loadSave(worldPath) +} + +export default () => { + const worlds = useSnapshot(worldsProxy).value as WorldProps[] + const active = useIsModalActive('singleplayer') + + useEffect(() => { + if (!active) return + readWorlds() + }, [active]) + + if (!active) return null + + return { + const worldPath = `/data/worlds/${worldName}` + if (action === 'load') { + await loadInMemorySave(worldPath) + return + } + if (action === 'delete') { + if (!confirm('Are you sure you want to delete current world')) return + setLoadingScreenStatus(`Removing world ${worldName}`) + await removeFileRecursiveAsync(worldPath) + setLoadingScreenStatus(undefined) + readWorlds() + } + if (action === 'export') { + const selectedVariant = + window.showDirectoryPicker + ? await showOptionsModal('Select export type', ['Select folder (recommended)', 'Download ZIP file']) + : await showOptionsModal('Select export type', ['Download ZIP file']) + if (!selectedVariant) return + if (selectedVariant === 'Select folder (recommended)') { + const success = await mountExportFolder() + if (!success) return + } + await exportWorld(worldPath, selectedVariant === 'Select folder (recommended)' ? 'folder' : 'zip', worldName) + } + }} + onGeneralAction={(action) => { + if (action === 'cancel') { + hideCurrentModal() + } + if (action === 'create') { + showModal({ reactType: 'create-world' }) + } + }} + /> +} diff --git a/src/react/loadingErrorScreen.module.css b/src/react/appStatus.module.css similarity index 100% rename from src/react/loadingErrorScreen.module.css rename to src/react/appStatus.module.css diff --git a/src/react/button.module.css b/src/react/button.module.css index 6b509d125..98997ab41 100644 --- a/src/react/button.module.css +++ b/src/react/button.module.css @@ -35,7 +35,7 @@ top: 0; width: 50%; height: calc(20px * var(--scale)); - background: var(--widgets-gui-atlas); + background: var(--widgets-gui-atlas), rgb(114, 114, 114); background-size: calc(256px * var(--scale)); background-position-y: calc(var(--txrV) * -1 * var(--scale)); z-index: -1; diff --git a/src/react/createWorld.module.css b/src/react/createWorld.module.css new file mode 100644 index 000000000..4def4a114 --- /dev/null +++ b/src/react/createWorld.module.css @@ -0,0 +1,10 @@ +.world_layers_container { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + background: rgba(0, 0, 0, 0.5); +} +.world_layer { + +} diff --git a/src/react/input.module.css b/src/react/input.module.css new file mode 100644 index 000000000..9ad01c901 --- /dev/null +++ b/src/react/input.module.css @@ -0,0 +1,39 @@ +.container { + position: relative; + width: 200px; + height: 20px; + background: black; + border: 1px solid grey; + box-sizing: content-box; +} + +.input { + position: relative; + outline: none; + border: none; + background: none; + left: 1px; + width: calc(100% - 2px); + height: 100%; + font-family: minecraft, mojangles, monospace; + font-size: 10px; + color: white; + text-shadow: 1px 1px #222; +} + +.container:hover, + .container:focus-within { + border-color: white; + } + + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + input[type=number] { + appearance: textfield; + -moz-appearance: textfield; + } diff --git a/src/react/mainMenu.module.css b/src/react/mainMenu.module.css index aa42ef622..939cf7948 100644 --- a/src/react/mainMenu.module.css +++ b/src/react/mainMenu.module.css @@ -104,3 +104,9 @@ --top-offset: 10px } } + +.maps-provider { + position: fixed; + top: 5px; + left: 5px; +} diff --git a/src/react/singleplayer.module.css b/src/react/singleplayer.module.css new file mode 100644 index 000000000..41f9ac4f3 --- /dev/null +++ b/src/react/singleplayer.module.css @@ -0,0 +1,44 @@ +.root { + flex-direction: column; + justify-content: space-between; + align-items: center; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + background: rgba(0, 0, 0, 0.5); + /* take all available space */ + flex: 1; + margin: 5px; + overflow: auto; + scrollbar-width: thin; +} + +.world_root { + height: 40px; + width: 300px; + border: 1px solid transparent; + display: flex; + outline: none; +} +.world_info { + margin-left: 3px; + display: flex; + flex-direction: column; + font-size: 11px; +} +.world_image { + height: 100%; + filter: grayscale(1); + aspect-ratio: 1; +} +.world_root.world_focused { + border-color: white; +} + +.title { + margin-top: 10px; +} diff --git a/src/react/utils.ts b/src/react/utils.ts index 9bb86c207..6a7efa530 100644 --- a/src/react/utils.ts +++ b/src/react/utils.ts @@ -2,7 +2,7 @@ import { useSnapshot } from 'valtio' import { useEffect, useRef } from 'react' import { activeModalStack } from '../globalState' -export const isModalActive = (modal: string) => { +export const useIsModalActive = (modal: string) => { return useSnapshot(activeModalStack).at(-1)?.reactType === modal } diff --git a/src/reactUi.jsx b/src/reactUi.jsx index 2e31bbf65..302621284 100644 --- a/src/reactUi.jsx +++ b/src/reactUi.jsx @@ -12,7 +12,10 @@ import { options, watchValue } from './optionsStorage' import DeathScreenProvider from './react/DeathScreenProvider' import OptionsRenderApp from './react/OptionsRenderApp' import MainMenuRenderApp from './react/MainMenuRenderApp' -import AppStatus from './react/AppStatus' +import SingleplayerProvider from './react/SingleplayerProvider' +import CreateWorldProvider from './react/CreateWorldProvider' +import AppStatusProvider from './react/AppStatusProvider' +import SelectOption from './react/SelectOption' // todo useInterfaceState.setState({ @@ -54,8 +57,9 @@ watchValue(options, (o) => { const TouchControls = () => { // todo setting const usingTouch = useUsingTouch() + const { usingGamepadInput } = useSnapshot(miscUiState) - if (!usingTouch) return null + if (!usingTouch || usingGamepadInput) return null return (
{ return
- + + + + diff --git a/src/rendererUtils.ts b/src/rendererUtils.ts new file mode 100644 index 000000000..6b83fd011 --- /dev/null +++ b/src/rendererUtils.ts @@ -0,0 +1,27 @@ +import { subscribeKey } from 'valtio/utils' +import { gameAdditionalState } from './globalState' +import { options } from './optionsStorage' + +export const watchFov = () => { + const updateFov = () => { + if (!bot) return + let fovSetting = options.fov + // todo check values and add transition + if (bot.controlState.sprint && !bot.controlState.sneak) { + fovSetting += 5 + } + if (gameAdditionalState.isFlying) { + fovSetting += 5 + } + viewer.camera.fov = fovSetting + viewer.camera.updateProjectionMatrix() + } + updateFov() + subscribeKey(options, 'fov', updateFov) + subscribeKey(gameAdditionalState, 'isFlying', updateFov) + subscribeKey(gameAdditionalState, 'isSprinting', updateFov) + subscribeKey(gameAdditionalState, 'isSneaking', () => { + viewer.isSneaking = gameAdditionalState.isSneaking + viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch) + }) +} diff --git a/src/styles.css b/src/styles.css index ea0c4feff..a1a8a8ed2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -22,9 +22,15 @@ html { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); height: 100vh; overflow: hidden; + color: white; +} +body { --widgets-gui-atlas: url('minecraft-assets/minecraft-assets/data/1.17.1/gui/widgets.png'); --title-gui: url('minecraft-assets/minecraft-assets/data/1.17.1/gui/title/minecraft.png'); - color: white; +} +body.disable-assets { + --widgets-gui-atlas: none; + --title-gui: none; } body { @@ -119,6 +125,10 @@ body { animation-fill-mode: forwards; } +.muted { + color: #999; +} + @keyframes dive-animation { 0% { transform: translateZ(-150px); diff --git a/src/texturePack.ts b/src/texturePack.ts index 1e975522b..d17aa5d9e 100644 --- a/src/texturePack.ts +++ b/src/texturePack.ts @@ -4,9 +4,10 @@ import JSZip from 'jszip' import type { Viewer } from 'prismarine-viewer/viewer/lib/viewer' import { subscribeKey } from 'valtio/utils' import { proxy, ref } from 'valtio' +import { getVersion } from 'prismarine-viewer/viewer/lib/version' import blocksFileNames from '../generated/blocks.json' -import type { BlockStates } from './inventory' -import { removeFileRecursiveAsync } from './browserfs' +import type { BlockStates } from './playerWindows' +import { copyFilesAsync, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs' import { setLoadingScreenStatus } from './utils' import { showNotification } from './globalState' @@ -27,19 +28,7 @@ function nextPowerOfTwo (n) { return n + 1 } -const mkdirRecursive = async (path) => { - const parts = path.split('/') - let current = '' - for (const part of parts) { - current += part + '/' - try { - await fs.promises.mkdir(current) - } catch (err) { - } - } -} - -const texturePackBasePath = '/userData/resourcePacks/default' +const texturePackBasePath = '/data/resourcePacks/default' export const uninstallTexturePack = async () => { await removeFileRecursiveAsync(texturePackBasePath) setCustomTexturePackData(undefined, undefined) @@ -65,6 +54,12 @@ export const updateTexturePackInstalledState = async () => { } } +export const installTexturePackFromHandle = async () => { + await mkdirRecursive(texturePackBasePath) + await copyFilesAsyncWithProgress('/world', texturePackBasePath) + await completeTexturePackInstall() +} + export const installTexturePack = async (file: File | ArrayBuffer, name = file['name']) => { try { await uninstallTexturePack() @@ -91,6 +86,10 @@ export const installTexturePack = async (file: File | ArrayBuffer, name = file[' done++ upStatus() })) + await completeTexturePackInstall(name) +} + +export const completeTexturePackInstall = async (name?: string) => { await fs.promises.writeFile(join(texturePackBasePath, 'name.txt'), name ?? '??', 'utf8') if (viewer?.world.active) { @@ -120,7 +119,7 @@ type TextureResolvedData = { const arrEqual = (a: any[], b: any[]) => a.length === b.length && a.every((x) => b.includes(x)) const applyTexturePackData = async (version: string, { blockSize }: TextureResolvedData, blocksUrlContent: string) => { - const result = await fetch(`blocksStates/${version}.json`) + const result = await fetch(`blocksStates/${getVersion(version)}.json`) const blockStates: BlockStates = await result.json() const factor = blockSize / 16 @@ -170,11 +169,11 @@ const getSizeFromImage = async (filePath: string) => { export const genTexturePackTextures = async (version: string) => { setCustomTexturePackData(undefined, undefined) - let blocksBasePath = '/userData/resourcePacks/default/assets/minecraft/textures/block' + let blocksBasePath = '/data/resourcePacks/default/assets/minecraft/textures/block' // todo not clear why this is needed - const blocksBasePathAlt = '/userData/resourcePacks/default/assets/minecraft/textures/blocks' - const blocksGeneratedPath = `/userData/resourcePacks/default/${version}.png` - const generatedPathData = `/userData/resourcePacks/default/${version}.json` + const blocksBasePathAlt = '/data/resourcePacks/default/assets/minecraft/textures/blocks' + const blocksGeneratedPath = `/data/resourcePacks/default/${version}.png` + const generatedPathData = `/data/resourcePacks/default/${version}.json` if (!(await existsAsync(blocksBasePath))) { if (await existsAsync(blocksBasePathAlt)) { blocksBasePath = blocksBasePathAlt @@ -214,7 +213,7 @@ export const genTexturePackTextures = async (version: string) => { const canvas = document.createElement('canvas') canvas.width = imgSize canvas.height = imgSize - const src = `textures/${version}.png` + const src = `textures/${getVersion(version)}.png` const ctx = canvas.getContext('2d') ctx.imageSmoothingEnabled = false const img = new Image() @@ -271,8 +270,8 @@ export const genTexturePackTextures = async (version: string) => { export const watchTexturepackInViewer = (viewer: Viewer) => { subscribeKey(resourcePackState, 'currentTexturesDataUrl', () => { console.log('applying resourcepack world data') - viewer.world.texturesDataUrl = resourcePackState.currentTexturesDataUrl - viewer.world.blockStatesData = resourcePackState.currentTexturesBlockStates + viewer.world.customTexturesDataUrl = resourcePackState.currentTexturesDataUrl + viewer.world.customBlockStatesData = resourcePackState.currentTexturesBlockStates if (!viewer?.world.active) return viewer.world.updateTexturesData() }) diff --git a/src/utils.ts b/src/utils.ts index 1af37722d..d4aaeb6b0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,7 +2,7 @@ import { hideModal, isGameActive, miscUiState, notification, showModal } from '. import { options } from './optionsStorage' import { openWorldZip } from './browserfs' import { installTexturePack } from './texturePack' -import { appStatusState } from './react/AppStatus' +import { appStatusState } from './react/AppStatusProvider' import { saveServer } from './flyingSquidUtils' export const goFullscreen = async (doToggle = false) => { @@ -81,7 +81,7 @@ export async function getScreenRefreshRate (): Promise { const fps = Math.floor(1000 * 10 / (DOMHighResTimeStamp - t0)) if (!callbackTriggered) { - resolve(fps/* , DOMHighResTimeStampCollection */) + resolve(Math.max(fps, 1000)/* , DOMHighResTimeStampCollection */) } callbackTriggered = true @@ -153,7 +153,7 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul export const disconnect = async () => { - if (window.localServer) { + if (localServer) { await saveServer() localServer.quit() } diff --git a/src/blockInteraction.js b/src/worldInteractions.js similarity index 83% rename from src/blockInteraction.js rename to src/worldInteractions.js index a88a8cb2e..93602b961 100644 --- a/src/blockInteraction.js +++ b/src/worldInteractions.js @@ -23,13 +23,22 @@ function getViewDirection (pitch, yaw) { return new Vec3(-snYaw * csPitch, snPitch, -csYaw * csPitch) } -class BlockInteraction { +class WorldInteraction { static instance = null /** @type {null | {blockPos,mesh}} */ interactionLines = null + prevBreakState + currentDigTime + prevOnGround init () { bot.on('physicsTick', () => { if (this.lastBlockPlaced < 4) this.lastBlockPlaced++ }) + bot.on('diggingCompleted', () => { + this.breakStartTime = undefined + }) + bot.on('diggingAborted', () => { + this.breakStartTime = undefined + }) // Init state this.buttons = [false, false, false] @@ -153,9 +162,23 @@ class BlockInteraction { this.lastBlockPlaced = 0 } + // Stop break + if ((!this.buttons[0] && this.lastButtons[0]) || cursorChanged) { + try { + bot.stopDigging() // this shouldnt throw anything... + } catch (e) { } // to be reworked in mineflayer, then remove the try here + } + + const onGround = bot.entity.onGround || bot.game.gameMode === 'creative' + this.prevOnGround ??= onGround // todo this should be fixed in mineflayer to involve correct calculations when this changes as this is very important when mining straight down // todo this should be fixed in mineflayer to involve correct calculations when this changes as this is very important when mining straight down // todo this should be fixed in mineflayer to involve correct calculations when this changes as this is very important when mining straight down // Start break // todo last check doesnt work as cursorChanged happens once (after that check is false) - if (cursorBlockDiggable && this.buttons[0] && (!this.lastButtons[0] || (cursorChanged && Date.now() - (this.lastDigged ?? 0) > 100))) { + if ( + cursorBlockDiggable && this.buttons[0] + && (!this.lastButtons[0] || (cursorChanged && Date.now() - (this.lastDigged ?? 0) > 100) || onGround !== this.prevOnGround) + && onGround + ) { + this.currentDigTime = bot.digTime(cursorBlock) this.breakStartTime = performance.now() bot.dig(cursorBlock, 'ignore').catch((err) => { if (err.message === 'Digging aborted') return @@ -163,13 +186,7 @@ class BlockInteraction { }) this.lastDigged = Date.now() } - - // Stop break - if (!this.buttons[0] && this.lastButtons[0]) { - try { - bot.stopDigging() // this shouldnt throw anything... - } catch (e) { } // to be reworked in mineflayer, then remove the try here - } + this.prevOnGround = onGround // Show cursor if (cursorBlock) { @@ -199,11 +216,19 @@ class BlockInteraction { } // Show break animation - if (cursorBlockDiggable && this.buttons[0]) { + if (this.breakStartTime && bot.game.gameMode !== 'creative') { const elapsed = performance.now() - this.breakStartTime const time = bot.digTime(cursorBlock) + if (time !== this.currentDigTime) { + console.warn('dig time changed! cancelling!', time, 'from', this.currentDigTime) // todo + try { bot.stopDigging() } catch { } + } const state = Math.floor((elapsed / time) * 10) - this.blockBreakMesh.material.map = this.breakTextures[state] + this.blockBreakMesh.material.map = this.breakTextures[state] ?? this.breakTextures.at(-1) + if (state !== this.prevBreakState) { + this.blockBreakMesh.material.needsUpdate = true + } + this.prevBreakState = state this.blockBreakMesh.visible = true } else { this.blockBreakMesh.visible = false @@ -228,4 +253,4 @@ const getDataFromShape = (shape) => { return { position, width, height, depth } } -export default new BlockInteraction() +export default new WorldInteraction()