Merge remote-tracking branch 'origin/next' into worker-rewrite

This commit is contained in:
Vitaly Turovsky 2025-03-12 18:26:20 +03:00
commit c228b91d2d
32 changed files with 6387 additions and 4979 deletions

31
.github/workflows/build-single-file.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: build-single-file
on:
workflow_dispatch:
jobs:
build-and-bundle:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: minecraft.html
path: minecraft.html

View file

@ -53,6 +53,8 @@ jobs:
publish_dir: .vercel/output/static
force_orphan: true
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
- name: Build self-host version
run: pnpm build
- name: Bundle server.js

View file

@ -1,6 +1,6 @@
{
"name": "Prismarine Web Client",
"short_name": "Prismarine Web Client",
"name": "Minecraft Web Client",
"short_name": "Minecraft Web Client",
"scope": "./",
"start_url": "./",
"icons": [

View file

@ -1,18 +1,29 @@
{
"version": 1,
"defaultHost": "<from-proxy>",
"defaultProxy": "proxy.mcraft.fun",
"defaultProxy": "https://proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/",
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "ws://mcraft.ryzyn.xyz",
"ip": "wss://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "grim.mcraft.fun",
"version": "1.19.4"
},
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",
"description": "Very nice a polite server. Must try for everyone!"
}
],
"pauseLinks": [

View file

@ -128,21 +128,17 @@
window.loadedPlugins[pluginName] = await import(script)
}
</script> -->
<title>Prismarine Web Client</title>
<link rel="favicon" href="favicon.png">
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="canonical" href="https://mcraft.fun">
<title>Minecraft Web Client</title>
<!-- <link rel="canonical" href="https://mcraft.fun"> -->
<meta name="description" content="Minecraft web client running in your browser">
<meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js">
<meta name="date" content="2024-07-11" scheme="YYYY-MM-DD">
<meta name="language" content="English">
<meta name="theme-color" content="#349474">
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'>
<meta property="og:title" content="Prismarine Web Client" />
<meta property="og:title" content="Minecraft Web Client" />
<meta property="og:type" content="website" />
<meta property="og:image" content="favicon.png" />
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
</head>
<body>
<div id="react-root"></div>

View file

@ -9,7 +9,9 @@
"start2": "run-p dev-rsbuild watch-mesher",
"build": "pnpm build-other-workers && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
"check-build": "tsx scripts/genShims.ts && tsc && pnpm build",
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
"prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts",
"check-build": "pnpm prepare-project && tsc && pnpm build",
"test:cypress": "cypress run",
"test:cypress:open": "cypress open",
"test-unit": "vitest",
@ -36,7 +38,7 @@
"client"
],
"release": {
"attachReleaseFiles": "self-host.zip"
"attachReleaseFiles": "{self-host.zip,minecraft.html}"
},
"publish": {
"preset": {
@ -149,7 +151,7 @@
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.42",
"mineflayer-mouse": "^0.0.7",
"mineflayer-mouse": "^0.0.8",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:GenerelSchwerz/mineflayer",
"mineflayer-pathfinder": "^2.4.4",
@ -171,6 +173,7 @@
"optionalDependencies": {
"cypress": "^10.11.0",
"cypress-plugin-snapshots": "^1.4.4",
"sharp": "^0.33.5",
"systeminformation": "^5.21.22"
},
"browserslist": {

10609
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -78,6 +78,12 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
if (absolute.endsWith('/minecraft-data/data.js')) {
resource.request = path.join(__dirname, `../src/shims/minecraftData.ts`)
}
if (absolute.endsWith('/minecraft-data/data/bedrock/common/legacy.json')) {
resource.request = path.join(__dirname, `../src/shims/empty.ts`)
}
if (absolute.endsWith('/minecraft-data/data/pc/common/legacy.json')) {
resource.request = path.join(__dirname, `../src/preflatMap.json`)
}
}))
addRules([
{
@ -91,11 +97,17 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
{
test: /\.mp3$/,
type: 'asset/source',
},
{
test: /\.txt$/,
type: 'asset/source',
}
])
config.ignoreWarnings = [
/the request of a dependency is an expression/,
/Unsupported pseudo class or element: xr-overlay/
]
if (process.env.SINGLE_FILE_BUILD === 'true') {
config.module!.parser!.javascript!.dynamicImportMode = 'eager'
}
}

View file

@ -190,7 +190,14 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// eslint-disable-next-line node/no-path-concat
const src = typeof window === 'undefined' ? `${__dirname}/${workerName}` : workerName
const worker: any = new Worker(src)
let worker: any
if (process.env.SINGLE_FILE_BUILD) {
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
const blob = new Blob([workerCode], { type: 'text/javascript' })
worker = new Worker(window.URL.createObjectURL(blob))
} else {
worker = new Worker(src)
}
const handleMessage = (data) => {
if (!this.active) return
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
@ -331,6 +338,14 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.version = version
this.texturesVersion = texturesVersion
this.resetWorld()
// for workers in single file build
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve)
})
}
this.initWorkers()
this.active = true
this.mesherConfig.outputFormat = this.outputFormat

View file

@ -11,6 +11,17 @@ import { promisify } from 'util'
import { generateSW } from 'workbox-build'
import { getSwAdditionalEntries } from './scripts/build'
import { appAndRendererSharedConfig } from './renderer/rsbuildSharedConfig'
import { genLargeDataAliases } from './scripts/genLargeDataAliases'
import sharp from 'sharp'
import supportedVersions from './src/supportedVersions.mjs'
const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true'
if (SINGLE_FILE_BUILD) {
const patchCssFile = 'node_modules/pixelarticons/fonts/pixelart-icons-font.css'
const text = fs.readFileSync(patchCssFile, 'utf8')
fs.writeFileSync(patchCssFile, text.replaceAll("url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'),", ""), 'utf8')
}
//@ts-ignore
try { require('./localSettings.js') } catch { }
@ -43,12 +54,54 @@ if (dev) {
configJson.defaultProxy = ':8080'
}
const configSource = process.env.CONFIG_JSON_SOURCE || 'REMOTE'
const configSource = (SINGLE_FILE_BUILD ? 'BUNDLED' : (process.env.CONFIG_JSON_SOURCE || 'REMOTE')) as 'BUNDLED' | 'REMOTE'
const faviconPath = 'favicon.png'
// base options are in ./renderer/rsbuildSharedConfig.ts
const appConfig = defineConfig({
html: {
template: './index.html',
inject: 'body',
tags: [
...SINGLE_FILE_BUILD ? [] : [
{
tag: 'link',
attrs: {
rel: 'manifest',
crossorigin: 'use-credentials',
href: 'manifest.json'
},
}
],
// <link rel="favicon" href="favicon.png">
// <link rel="icon" type="image/png" href="favicon.png" />
// <meta property="og:image" content="favicon.png" />
{
tag: 'link',
attrs: {
rel: 'favicon',
href: faviconPath
}
},
...SINGLE_FILE_BUILD ? [] : [
{
tag: 'link',
attrs: {
rel: 'icon',
type: 'image/png',
href: faviconPath
}
},
{
tag: 'meta',
attrs: {
property: 'og:image',
content: faviconPath
}
}
]
]
},
output: {
externals: [
@ -58,6 +111,13 @@ const appConfig = defineConfig({
js: 'source-map',
css: true,
},
distPath: SINGLE_FILE_BUILD ? {
html: './single',
} : undefined,
inlineScripts: SINGLE_FILE_BUILD,
inlineStyles: SINGLE_FILE_BUILD,
// 50kb limit for data uri
dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024
},
source: {
entry: {
@ -69,6 +129,9 @@ const appConfig = defineConfig({
define: {
'process.env.BUILD_VERSION': JSON.stringify(!dev ? buildingVersion : 'undefined'),
'process.env.MAIN_MENU_LINKS': JSON.stringify(process.env.MAIN_MENU_LINKS),
'process.env.SINGLE_FILE_BUILD': JSON.stringify(process.env.SINGLE_FILE_BUILD),
'process.env.SINGLE_FILE_BUILD_MODE': JSON.stringify(process.env.SINGLE_FILE_BUILD),
'process.platform': '"browser"',
'process.env.GITHUB_URL':
JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}` || githubRepositoryFallback}`),
'process.env.DEPS_VERSIONS': JSON.stringify({}),
@ -92,17 +155,19 @@ const appConfig = defineConfig({
pluginTypedCSSModules(),
{
name: 'test',
setup (build: RsbuildPluginAPI) {
setup(build: RsbuildPluginAPI) {
const prep = async () => {
console.time('total-prep')
fs.mkdirSync('./generated', { recursive: true })
if (!fs.existsSync('./generated/minecraft-data-optimized.json') || require('./generated/minecraft-data-optimized.json').versionKey !== require('minecraft-data/package.json').version) {
if (!fs.existsSync('./generated/minecraft-data-optimized.json') || !fs.existsSync('./generated/mc-assets-compressed.js') || require('./generated/minecraft-data-optimized.json').versionKey !== require('minecraft-data/package.json').version) {
childProcess.execSync('tsx ./scripts/makeOptimizedMcData.mjs', { stdio: 'inherit' })
}
childProcess.execSync('tsx ./scripts/genShims.ts', { stdio: 'inherit' })
if (!fs.existsSync('./generated/latestBlockCollisionsShapes.json') || require('./generated/latestBlockCollisionsShapes.json').versionKey !== require('minecraft-data/package.json').version) {
childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' })
}
// childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' })
genLargeDataAliases(SINGLE_FILE_BUILD)
fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity')
fsExtra.copySync('./assets/background', './dist/background')
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
@ -137,16 +202,33 @@ const appConfig = defineConfig({
prep()
})
build.onAfterBuild(async () => {
if (!disableServiceWorker) {
if (SINGLE_FILE_BUILD) {
// process index.html
const singleBuildHtml = './dist/single/index.html'
let html = fs.readFileSync(singleBuildHtml, 'utf8')
const verToMajor = (ver: string) => ver.split('.').slice(0, 2).join('.')
const supportedMajorVersions = [...new Set(supportedVersions.map(a => verToMajor(a)))].join(', ')
html = `<!DOCTYPE html><!-- MINECRAFT WEB CLIENT ${releaseTag ?? ''} -->\n<!-- A true SINGLE FILE BUILD with built-in server -->\n<!-- All textures, assets and Minecraft data for ${supportedMajorVersions} inlined into one file. -->\n${html}`
const resizedImage = (await (sharp('./assets/favicon.png') as any).resize(64).toBuffer()).toString('base64')
html = html.replace('favicon.png', `data:image/png;base64,${resizedImage}`)
html = html.replace('src="./loading-bg.jpg"', `src="data:image/png;base64,${fs.readFileSync('./assets/loading-bg.jpg', 'base64')}"`)
html += '<script id="mesher-worker-code">' + fs.readFileSync('./dist/mesher.js', 'utf8') + '</script>'
fs.writeFileSync(singleBuildHtml, html, 'utf8')
// write output file size
console.log('single file size', (fs.statSync(singleBuildHtml).size / 1024 / 1024).toFixed(2), 'mb')
} else {
if (!disableServiceWorker) {
const { count, size, warnings } = await generateSW({
// dontCacheBustURLsMatching: [new RegExp('...')],
globDirectory: 'dist',
skipWaiting: true,
clientsClaim: true,
additionalManifestEntries: getSwAdditionalEntries(),
globPatterns: [],
swDest: './dist/service-worker.js',
})
// dontCacheBustURLsMatching: [new RegExp('...')],
globDirectory: 'dist',
skipWaiting: true,
clientsClaim: true,
additionalManifestEntries: getSwAdditionalEntries(),
globPatterns: [],
swDest: './dist/service-worker.js',
})
}
}
})
}

View file

@ -0,0 +1,59 @@
import * as fs from 'fs'
export const genLargeDataAliases = async (isCompressed: boolean) => {
const modules = {
mcData: {
raw: '../generated/minecraft-data-optimized.json',
compressed: '../generated/mc-data-compressed.js',
},
blockStatesModels: {
raw: 'mc-assets/dist/blockStatesModels.json',
compressed: '../generated/mc-assets-compressed.js',
}
}
const OUT_FILE = './generated/large-data-aliases.ts'
let str = `${decoderCode}\nexport const importLargeData = async (mod: ${Object.keys(modules).map(x => `'${x}'`).join(' | ')}) => {\n`
for (const [module, { compressed, raw }] of Object.entries(modules)) {
let importCode = `(await import('${isCompressed ? compressed : raw}')).default`;
if (isCompressed) {
importCode = `JSON.parse(decompressFromBase64(${importCode}))`
}
str += ` if (mod === '${module}') return ${importCode}\n`
}
str += `}\n`
fs.writeFileSync(OUT_FILE, str, 'utf8')
}
const decoderCode = /* ts */ `
import pako from 'pako';
function decompressFromBase64(input) {
console.time('decompressFromBase64')
// Decode the Base64 string
const binaryString = atob(input);
const len = binaryString.length;
const bytes = new Uint8Array(len);
// Convert the binary string to a byte array
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Decompress the byte array
const decompressedData = pako.inflate(bytes, { to: 'string' });
console.timeEnd('decompressFromBase64')
return decompressedData;
}
`
// execute if run directly
if (require.main === module) {
console.log('running...')
const isCompressed = process.argv.includes('--compressed')
genLargeDataAliases(isCompressed)
console.log('done generating large data aliases')
}

View file

@ -15,6 +15,7 @@ for (const resource of appReplacableResources) {
const name = path.split('/').slice(-4).join('_').replace('.png', '').replaceAll('-', '_').replaceAll('.', '_')
keys.push(name)
headerImports += `import ${name} from '${path.replace('../node_modules/', '')}'\n`
resourcesContent += `
'${name}': {
content: ${name},

View file

@ -42,14 +42,20 @@ const versionToNumber = (ver) => {
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
}
const compressedOutput = false
// if not included here (even as {}) will not be bundled & accessible!
// const dataTypeBundling = {
// protocol: {
// // ignoreRemoved: true,
// // ignoreChanges: true
// }
// }
// const compressedOutput = !!process.env.SINGLE_FILE_BUILD
const compressedOutput = true
const dataTypeBundling2 = {
blocks: {
arrKey: 'name',
},
items: {
arrKey: 'name',
},
recipes: {
processData: processRecipes
}
}
const dataTypeBundling = {
language: {
ignoreRemoved: true,
@ -132,7 +138,8 @@ const dataTypeBundling = {
},
recipes: {
raw: true
}, // todo we can do better
// processData: processRecipes
},
blockCollisionShapes: {},
loginPacket: {},
protocol: {
@ -143,6 +150,84 @@ const dataTypeBundling = {
// }
}
function processRecipes (current, prev, getData, version) {
// can require the same multiple times per different versions
if (current._proccessed) return
const items = getData('items')
const blocks = getData('blocks')
const itemsIdsMap = Object.fromEntries(items.map((b) => [b.id, b.name]))
const blocksIdsMap = Object.fromEntries(blocks.map((b) => [b.id, b.name]))
for (const key of Object.keys(current)) {
const mapId = (id) => {
if (typeof id !== 'string' && typeof id !== 'number') throw new Error('Incorrect type')
const mapped = itemsIdsMap[id] ?? blocksIdsMap[id]
if (!mapped) {
throw new Error(`No item/block name with id ${id}`)
}
return mapped
}
const processRecipe = (obj) => {
// if (!obj) return
// if (Array.isArray(obj)) {
// obj.forEach((id, i) => {
// obj[i] = mapId(obj[id])
// })
// } else if (obj && typeof obj === 'object') {
// if (!'count metadata id'.split(' ').every(x => x in obj)) {
// throw new Error(`process error: Unknown deep object pattern: ${JSON.stringify(obj)}`)
// }
// obj.id = mapId(obj.id)
// } else {
// throw new Error('unknown type')
// }
const parseRecipeItem = (item) => {
if (typeof item === 'number') return mapId(item)
if (Array.isArray(item)) return [mapId(item), ...item.slice(1)]
if (!item) {
return item
}
if ('id' in item) {
item.id = mapId(item.id)
return item
}
throw new Error('unhandled')
}
const maybeProccessShape = (shape) => {
if (!shape) return
for (const shapeRow of shape) {
for (const [i, item] of shapeRow.entries()) {
shapeRow[i] = parseRecipeItem(item)
}
}
}
if (obj.result) obj.result = parseRecipeItem(obj.result)
maybeProccessShape(obj.inShape)
maybeProccessShape(obj.outShape)
if (obj.ingredients) {
for (const [i, ingredient] of obj.ingredients.entries()) {
obj.ingredients[i] = parseRecipeItem(ingredient)
}
}
}
try {
const name = mapId(key)
for (const [i, recipe] of current[key].entries()) {
try {
processRecipe(recipe)
} catch (err) {
console.warn(`${version} [warn] Removing incorrect recipe: ${err}`)
delete current[i]
}
}
current[name] = current[key]
} catch (err) {
console.warn(`${version} [warn] Removing incorrect recipe: ${err}`)
}
delete current[key]
}
current._proccessed = true
}
const notBundling = [...dataTypes.keys()].filter(x => !Object.keys(dataTypeBundling).includes(x))
console.log("Not bundling minecraft-data data:", notBundling)
@ -161,11 +246,15 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) {
// contents += ` get ${dataType} () { return window.globalGetCollisionShapes?.("${version}") },\n`
continue
}
const loc = `minecraft-data/data/${dataPath}/`
const dataPathAbsolute = require.resolve(`minecraft-data/${loc}${dataType}`)
// const data = fs.readFileSync(dataPathAbsolute, 'utf8')
const dataRaw = require(dataPathAbsolute)
let injectCode = ''
const getData = (type) => {
const loc = `minecraft-data/data/${dataSet[type]}/`
const dataPathAbsolute = require.resolve(`minecraft-data/${loc}${type}`)
// const data = fs.readFileSync(dataPathAbsolute, 'utf8')
const dataRaw = require(dataPathAbsolute)
return dataRaw
}
const dataRaw = getData(dataType)
let rawData = dataRaw
if (config.raw) {
rawDataVersions[dataType] ??= {}
@ -176,7 +265,7 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) {
diffSources[dataType] = new JsonOptimizer(config.arrKey, config.ignoreChanges, config.ignoreRemoved)
}
try {
config.processData?.(dataRaw, previousData[dataType])
config.processData?.(dataRaw, previousData[dataType], getData, version)
diffSources[dataType].recordDiff(version, dataRaw)
injectCode = `restoreDiff(sources, ${JSON.stringify(dataType)}, ${JSON.stringify(version)})`
} catch (err) {
@ -225,8 +314,14 @@ fs.writeFileSync(filePath, JSON.stringify(sources), 'utf8')
if (compressedOutput) {
const minizedCompressed = compressToBase64(fs.readFileSync(filePath))
console.log('size of compressed', Buffer.byteLength(minizedCompressed, 'utf8') / 1000 / 1000)
const compressedFilePath = './experiments/compressed.js'
fs.writeFileSync(compressedFilePath, minizedCompressed, 'utf8')
const compressedFilePath = './generated/mc-data-compressed.js'
fs.writeFileSync(compressedFilePath, `export default ${JSON.stringify(minizedCompressed)}`, 'utf8')
const mcAssets = JSON.stringify(require('mc-assets/dist/blockStatesModels.json'))
fs.writeFileSync('./generated/mc-assets-compressed.js', `export default ${JSON.stringify(compressToBase64(mcAssets))}`, 'utf8')
// const modelsObj = fs.readFileSync('./prismarine-renderer/viewer/lib/entity/exportedModels.js')
// const models =
}
console.log('size', fs.lstatSync(filePath).size / 1000 / 1000, gzipSizeFromFileSync(filePath) / 1000 / 1000)

View file

@ -1,5 +1,5 @@
import assert from 'assert'
import JsonOptimizer from '../src/optimizeJson';
import JsonOptimizer, { restoreMinecraftData } from '../src/optimizeJson';
import fs from 'fs'
import minecraftData from 'minecraft-data'
@ -8,7 +8,7 @@ const json = JSON.parse(fs.readFileSync('./generated/minecraft-data-optimized.js
const dataPaths = require('minecraft-data/minecraft-data/data/dataPaths.json')
const validateData = (ver, type) => {
const target = JsonOptimizer.restoreData(structuredClone(json[type]), ver)
const target = restoreMinecraftData(structuredClone(json), type, ver)
const arrKey = json[type].arrKey
const originalPath = dataPaths.pc[ver][type]
const original = require(`minecraft-data/minecraft-data/data/${originalPath}/${type}.json`)
@ -92,8 +92,8 @@ const checkKeys = (source, diffing, isUniq = true, msg = '', redundantIsOk = fal
}
// const data = minecraftData('1.20.4')
const oldId = JsonOptimizer.restoreData(json['blocks'], '1.20').find(x => x.name === 'brown_stained_glass').id;
const newId = JsonOptimizer.restoreData(json['blocks'], '1.20.4').find(x => x.name === 'brown_stained_glass').id;
const oldId = JsonOptimizer.restoreData(json['blocks'], '1.20', undefined).find(x => x.name === 'brown_stained_glass').id;
const newId = JsonOptimizer.restoreData(json['blocks'], '1.20.4', undefined).find(x => x.name === 'brown_stained_glass').id;
assert(oldId !== newId)
// test all types + all versions

View file

@ -1,9 +1,15 @@
// import { versionsByMinecraftVersion } from 'minecraft-data'
// import minecraftInitialDataJson from '../generated/minecraft-initial-data.json'
import { AuthenticatedAccount } from './react/serversStorage'
import { downloadSoundsIfNeeded } from './sounds/botSoundSystem'
import { options } from './optionsStorage'
import MinecraftData from 'minecraft-data'
import PrismarineBlock from 'prismarine-block'
import PrismarineItem from 'prismarine-item'
import pathfinder from 'mineflayer-pathfinder'
import { importLargeData } from '../generated/large-data-aliases'
import { miscUiState } from './globalState'
import supportedVersions from './supportedVersions.mjs'
import { options } from './optionsStorage'
import { downloadSoundsIfNeeded } from './sounds/botSoundSystem'
import { AuthenticatedAccount } from './react/serversStorage'
export type ConnectOptions = {
server?: string
@ -30,7 +36,7 @@ export type ConnectOptions = {
export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVersionSelect) => {
if (autoVersionSelect === 'auto') {
return '1.20.4'
return '1.19.4'
}
if (autoVersionSelect === 'latest') {
return supportedVersions.at(-1)!
@ -38,7 +44,8 @@ export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVers
return autoVersionSelect
}
export const downloadMcDataOnConnect = async (version: string) => {
export const loadMinecraftData = async (version: string, importBlockstatesModels = false) => {
await window._LOAD_MC_DATA()
// setLoadingScreenStatus(`Loading data for ${version}`)
// // todo expose cache
// // const initialDataVersion = Object.keys(minecraftInitialDataJson)[0]!
@ -47,8 +54,16 @@ export const downloadMcDataOnConnect = async (version: string) => {
// // versionsByMinecraftVersion.pc[initialDataVersion]!.dataVersion!++
// // }
// await window._MC_DATA_RESOLVER.promise // ensure data is loaded
// miscUiState.loadedDataVersion = version
const mcData = MinecraftData(version)
window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!)
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData
window.pathfinder = pathfinder
miscUiState.loadedDataVersion = version
if (importBlockstatesModels) {
viewer.world.blockstatesModels = await importLargeData('blockStatesModels')
}
}
export const downloadAllMinecraftData = async () => {

View file

@ -3,7 +3,9 @@
import fs from 'fs'
import { WorldRendererThree } from 'renderer/viewer/lib/worldrendererThree'
import { enable, disable, enabled } from 'debug'
import { Vec3 } from 'vec3'
window.Vec3 = Vec3
window.cursorBlockRel = (x = 0, y = 0, z = 0) => {
const newPos = bot.blockAtCursor(5)?.position.offset(x, y, z)
if (!newPos) return

View file

@ -21,8 +21,6 @@ import { Duplex } from 'stream'
import './scaleInterface'
import { initWithRenderer } from './topRightStats'
import PrismarineBlock from 'prismarine-block'
import PrismarineItem from 'prismarine-item'
import { options, watchValue } from './optionsStorage'
import './reactUi'
@ -37,7 +35,6 @@ import net from 'net'
import mineflayer from 'mineflayer'
import { WorldDataEmitter, Viewer } from 'renderer/viewer'
import pathfinder from 'mineflayer-pathfinder'
import { Vec3 } from 'vec3'
import * as THREE from 'three'
import MinecraftData from 'minecraft-data'
@ -87,7 +84,7 @@ import { saveToBrowserMemory } from './react/PauseScreen'
import { ViewerWrapper } from 'renderer/viewer/lib/viewerWrapper'
import './devReload'
import './water'
import { ConnectOptions, downloadMcDataOnConnect, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
import { subscribe } from 'valtio'
import { signInMessageState } from './react/SignInMessageProvider'
import { updateServerConnectionHistory } from './react/serversStorage'
@ -109,6 +106,7 @@ import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording'
import { createFullScreenProgressReporter } from './core/progressReporter'
import { getItemModelName } from './resourcesManager'
import { getProtocolClientGetter } from './protocolWorker/protocolMain'
import { importLargeData } from '../generated/large-data-aliases'
window.debug = debug
window.THREE = THREE
@ -410,7 +408,7 @@ export async function connect (connectOptions: ConnectOptions) {
await progress.executeWithMessage(
'Applying user-installed resource pack',
async () => {
await downloadMcDataOnConnect(version)
await loadMinecraftData(version)
try {
await resourcepackReload(version)
} catch (err) {
@ -426,7 +424,7 @@ export async function connect (connectOptions: ConnectOptions) {
await progress.executeWithMessage(
'Loading minecraft models',
async () => {
viewer.world.blockstatesModels = await import('mc-assets/dist/blockStatesModels.json')
viewer.world.blockstatesModels = await importLargeData('blockStatesModels')
void viewer.setVersion(version, options.useVersionsTextures === 'latest' ? version : options.useVersionsTextures)
miscUiState.loadedDataVersion = version
}
@ -693,13 +691,6 @@ export async function connect (connectOptions: ConnectOptions) {
bot.once('login', () => {
setLoadingScreenStatus('Loading world')
const mcData = MinecraftData(bot.version)
window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!)
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData
window.Vec3 = Vec3
window.pathfinder = pathfinder
})
const start = Date.now()
@ -826,8 +817,8 @@ export async function connect (connectOptions: ConnectOptions) {
miscUiState.gameLoaded = true
miscUiState.loadedServerIndex = connectOptions.serverIndex ?? ''
customEvents.emit('gameLoaded')
setLoadingScreenStatus(undefined)
progress.end()
setLoadingScreenStatus(undefined)
})
if (singleplayer && connectOptions.serverOverrides.worldFolder) {

View file

@ -18,6 +18,102 @@ type SourceData = {
__IS_OPTIMIZED__: true
}
function getRecipesProcessorProcessRecipes (items, blocks) {
return (current) => {
// can require the same multiple times per different versions
const itemsIdsMap = Object.fromEntries(items.map((b) => [b.name, b.id]))
const blocksIdsMap = Object.fromEntries(blocks.map((b) => [b.name, b.id]))
const keys = Object.keys(current)
for (const key of keys) {
if (key === '_proccessed') {
delete current[key]
continue
}
const mapId = (id) => {
if (typeof id !== 'string' && typeof id !== 'number') throw new Error('Incorrect type')
const mapped = itemsIdsMap[id] ?? blocksIdsMap[id]
if (!mapped) {
throw new Error(`No item/block name with id ${id}`)
}
return mapped
}
const processRecipe = (obj) => {
// if (!obj) return
// if (Array.isArray(obj)) {
// obj.forEach((id, i) => {
// obj[i] = mapId(obj[id])
// })
// } else if (obj && typeof obj === 'object') {
// if (!'count metadata id'.split(' ').every(x => x in obj)) {
// throw new Error(`process error: Unknown deep object pattern: ${JSON.stringify(obj)}`)
// }
// obj.id = mapId(obj.id)
// } else {
// throw new Error('unknown type')
// }
const parseRecipeItem = (item) => {
if (typeof item === 'number' || typeof item === 'string') return mapId(item)
if (Array.isArray(item)) return [mapId(item), ...item.slice(1)]
if (!item) {
return item
}
if ('id' in item) {
item.id = mapId(item.id)
return item
}
throw new Error('unhandled')
}
const maybeProccessShape = (shape) => {
if (!shape) return
for (const shapeRow of shape) {
for (const [i, item] of shapeRow.entries()) {
shapeRow[i] = parseRecipeItem(item)
}
}
}
if (obj.result) obj.result = parseRecipeItem(obj.result)
maybeProccessShape(obj.inShape)
maybeProccessShape(obj.outShape)
if (obj.ingredients) {
for (const [i, ingredient] of obj.ingredients.entries()) {
obj.ingredients[i] = parseRecipeItem(ingredient)
}
}
}
// eslint-disable-next-line no-useless-catch
try {
const name = mapId(key)
for (const [i, recipe] of current[key].entries()) {
// eslint-disable-next-line no-useless-catch
try {
processRecipe(recipe)
} catch (err) {
// console.warn(`${version} [warn] Removing incorrect recipe: ${err}`)
// delete current[i]
throw err
}
}
current[name] = current[key]
} catch (err) {
// console.warn(`${version} [warn] Removing incorrect recipe: ${err}`)
throw err
}
delete current[key]
}
}
}
export const restoreMinecraftData = (allVersionData: any, type: string, version: string) => {
let restorer
if (type === 'recipes') {
restorer = getRecipesProcessorProcessRecipes(
JsonOptimizer.restoreData(allVersionData.items, version, undefined),
JsonOptimizer.restoreData(allVersionData.blocks, version, undefined),
)
}
return JsonOptimizer.restoreData(allVersionData[type], version, restorer)
}
export default class JsonOptimizer {
keys = {} as IdMap
idToKey = {} as Record<number, string>
@ -146,6 +242,7 @@ export default class JsonOptimizer {
recordDiff (key: string, diffObj: string) {
const diff = this.diffObj(diffObj)
// problem is that 274 key 10.20.6 no removed keys in diff created
this.diffs[key] = diff
}
@ -158,7 +255,7 @@ export default class JsonOptimizer {
return true
}
static restoreData ({ keys, properties, source, arrKey, diffs }: SourceData, targetKey: string) {
static restoreData ({ keys, properties, source, arrKey, diffs }: SourceData, targetKey: string, dataRestorer: ((data) => void) | undefined) {
// if (!diffs[targetKey]) throw new Error(`The requested data to restore with key ${targetKey} does not exist`)
source = structuredClone(source)
const keysById = Object.fromEntries(Object.entries(keys).map(x => [x[1], x[0]]))
@ -204,11 +301,14 @@ export default class JsonOptimizer {
break
}
}
let data
if (arrKey) {
return Object.values(dataByKeys)
data = Object.values(dataByKeys)
} else {
return Object.fromEntries(Object.entries(dataByKeys).map(([key, val]) => [keysById[key], val]))
data = Object.fromEntries(Object.entries(dataByKeys).map(([key, val]) => [keysById[key], val]))
}
dataRestorer?.(data)
return data
}
static getByArrKey (item: any, arrKey: string) {

View file

@ -163,10 +163,12 @@ const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
export type AppOptions = typeof defaultOptions
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStorageKey = process.env.SINGLE_FILE_BUILD ? 'minecraftWebClientOptions' : 'options'
export const options: AppOptions = proxy({
...defaultOptions,
...initialAppConfig.defaultSettings,
...migrateOptions(JSON.parse(localStorage.options || '{}')),
...migrateOptions(JSON.parse(localStorage[localStorageKey] || '{}')),
...qsOptions
})
@ -185,7 +187,7 @@ Object.defineProperty(window, 'debugChangedOptions', {
subscribe(options, () => {
// Don't save disabled settings to localStorage
const saveOptions = omitObj(options, [...disabledSettings.value] as any)
localStorage.options = JSON.stringify(saveOptions)
localStorage[localStorageKey] = JSON.stringify(saveOptions)
})
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T, isChanged: boolean) => void) => () => void

View file

@ -1,78 +1,47 @@
//@ts-check
import { join } from 'path'
import fs from 'fs'
import * as THREE from 'three'
import { subscribeKey } from 'valtio/utils'
import { EntityMesh } from 'renderer/viewer/lib/entity/EntityMesh'
import { fromTexturePackPath, resourcePackState } from './resourcePack'
import { options, watchValue } from './optionsStorage'
import { WorldDataEmitter } from 'renderer/viewer'
import { Vec3 } from 'vec3'
import { getSyncWorld } from 'renderer/playground/shared'
import * as tweenJs from '@tweenjs/tween.js'
import { subscribeKey } from 'valtio/utils'
import { options } from './optionsStorage'
import { miscUiState } from './globalState'
import { loadMinecraftData } from './connect'
let panoramaCubeMap
let shouldDisplayPanorama = false
let panoramaUsesResourcePack = null as boolean | null
const panoramaFiles = [
'panorama_1.png', // WS
'panorama_3.png', // ES
'panorama_4.png', // Up
'panorama_5.png', // Down
'panorama_0.png', // NS
'panorama_2.png' // SS
'panorama_3.png', // right (+x)
'panorama_1.png', // left (-x)
'panorama_4.png', // top (+y)
'panorama_5.png', // bottom (-y)
'panorama_0.png', // front (+z)
'panorama_2.png', // back (-z)
]
const panoramaResourcePackPath = 'assets/minecraft/textures/gui/title/background'
const possiblyLoadPanoramaFromResourcePack = async (file) => {
let base64Texture
if (panoramaUsesResourcePack) {
try {
// TODO!
// base64Texture = await fs.promises.readFile(fromTexturePackPath(join(panoramaResourcePackPath, file)), 'base64')
} catch (err) {
panoramaUsesResourcePack = false
}
}
if (base64Texture) return `data:image/png;base64,${base64Texture}`
else return join('background', file)
}
const updateResourcePackSupportPanorama = async () => {
try {
// TODO!
// await fs.promises.readFile(fromTexturePackPath(join(panoramaResourcePackPath, panoramaFiles[0])), 'base64')
panoramaUsesResourcePack = true
} catch (err) {
panoramaUsesResourcePack = false
}
}
watchValue(miscUiState, m => {
if (m.fsReady) {
// 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()
})
let unloadPanoramaCallbacks = [] as Array<() => void>
// Menu panorama background
// TODO-low use abort controller
export async function addPanoramaCubeMap () {
if (panoramaCubeMap || miscUiState.loadedDataVersion || options.disableAssets) return
await new Promise(resolve => {
setTimeout(resolve, 0) // wait for viewer to be initialized
})
viewer.camera.fov = 85
if (process.env.SINGLE_FILE_BUILD_MODE) {
void initDemoWorld()
return
}
shouldDisplayPanorama = true
let time = 0
viewer.camera.fov = 85
viewer.camera.near = 0.05
viewer.camera.updateProjectionMatrix()
viewer.camera.position.set(0, 0, 0)
@ -81,12 +50,25 @@ export async function addPanoramaCubeMap () {
const loader = new THREE.TextureLoader()
const panorMaterials = [] as THREE.MeshBasicMaterial[]
await updateResourcePackSupportPanorama()
for (const file of panoramaFiles) {
const texture = loader.load(join('background', file))
// Instead of using repeat/offset to flip, we'll use the texture matrix
texture.matrixAutoUpdate = false
texture.matrix.set(
-1, 0, 1, 0, 1, 0, 0, 0, 1
)
texture.wrapS = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
texture.wrapT = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
panorMaterials.push(new THREE.MeshBasicMaterial({
map: loader.load(await possiblyLoadPanoramaFromResourcePack(file)),
map: texture,
transparent: true,
side: THREE.DoubleSide
side: THREE.DoubleSide,
depthWrite: false
}))
}
@ -120,12 +102,94 @@ export async function addPanoramaCubeMap () {
panoramaCubeMap = group
}
subscribeKey(miscUiState, 'fsReady', () => {
if (miscUiState.fsReady) {
// don't do it earlier to load fs and display menu faster
void addPanoramaCubeMap()
}
})
export function removePanorama () {
for (const unloadPanoramaCallback of unloadPanoramaCallbacks) {
unloadPanoramaCallback()
}
unloadPanoramaCallbacks = []
viewer.camera.fov = options.fov
shouldDisplayPanorama = false
if (!panoramaCubeMap) return
viewer.camera.fov = options.fov
viewer.camera.near = 0.1
viewer.camera.updateProjectionMatrix()
viewer.scene.remove(panoramaCubeMap)
panoramaCubeMap = null
}
const initDemoWorld = async () => {
const abortController = new AbortController()
unloadPanoramaCallbacks.push(() => {
abortController.abort()
})
const version = '1.21.4'
console.time(`load ${version} mc-data`)
await loadMinecraftData(version, true)
console.timeEnd(`load ${version} mc-data`)
if (abortController.signal.aborted) return
console.time('load scene')
const world = getSyncWorld(version)
const PrismarineBlock = require('prismarine-block')
const Block = PrismarineBlock(version)
const fullBlocks = loadedData.blocksArray.filter(block => {
// if (block.name.includes('leaves')) return false
if (/* !block.name.includes('wool') && */!block.name.includes('stained_glass')/* && !block.name.includes('terracotta') */) return false
const b = Block.fromStateId(block.defaultState, 0)
if (b.shapes?.length !== 1) return false
const shape = b.shapes[0]
return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1
})
const Z = -15
const sizeX = 100
const sizeY = 100
for (let x = -sizeX; x < sizeX; x++) {
for (let y = -sizeY; y < sizeY; y++) {
const block = fullBlocks[Math.floor(Math.random() * fullBlocks.length)]
world.setBlockStateId(new Vec3(x, y, Z), block.defaultState)
}
}
viewer.camera.updateProjectionMatrix()
viewer.camera.position.set(0.5, sizeY / 2 + 0.5, 0.5)
viewer.camera.rotation.set(0, 0, 0)
const initPos = new Vec3(...viewer.camera.position.toArray())
const worldView = new WorldDataEmitter(world, 2, initPos)
// worldView.addWaitTime = 0
await viewer.world.setVersion(version)
if (abortController.signal.aborted) return
viewer.connect(worldView)
void worldView.init(initPos)
await viewer.world.waitForChunksToRender()
if (abortController.signal.aborted) return
// add small camera rotation to side on mouse move depending on absolute position of the cursor
const { camera } = viewer
const initX = camera.position.x
const initY = camera.position.y
let prevTwin: tweenJs.Tween<THREE.Vector3> | undefined
document.body.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return
const pos = new THREE.Vector2(e.clientX, e.clientY)
const SCALE = 0.2
/* -0.5 - 0.5 */
const xRel = pos.x / window.innerWidth - 0.5
const yRel = -(pos.y / window.innerHeight - 0.5)
prevTwin?.stop()
const to = {
x: initX + (xRel * SCALE),
y: initY + (yRel * SCALE)
}
prevTwin = new tweenJs.Tween(camera.position).to(to, 0) // todo use the number depending on diff // todo use the number depending on diff
// prevTwin.easing(tweenJs.Easing.Exponential.InOut)
prevTwin.start()
camera.updateProjectionMatrix()
}, {
signal: abortController.signal
})
console.timeEnd('load scene')
}

View file

@ -71,7 +71,16 @@ export default ({
<>
{showReconnect && onReconnect && <Button label="Reconnect" onClick={onReconnect} />}
{actionsSlot}
<Button onClick={() => window.location.reload()} label="Reset App (recommended)" />
<Button
onClick={() => {
if (location.search) {
location.search = ''
} else {
window.location.reload()
}
}}
label="Reset App (recommended)"
/>
{!lockConnect && backAction && <Button label="Back" onClick={backAction} />}
</>
)}

View file

@ -87,7 +87,9 @@ export default () => {
const [versionTitle, setVersionTitle] = useState('')
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
if (process.env.SINGLE_FILE_BUILD_MODE) {
setVersionStatus('(single file build)')
} else if (process.env.NODE_ENV === 'development') {
setVersionStatus('(dev)')
} else {
fetch('./version.txt').then(async (f) => {

View file

@ -32,8 +32,7 @@ export default () => {
useEffect(() => {
function requestUpdate () {
// Placeholder for requestUpdate logic
setPlayers(bot.players)
setPlayers(bot?.players ?? {})
}
bot.on('playerUpdated', () => requestUpdate())

View file

@ -1,57 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import AddServerOrConnect from './AddServerOrConnect'
import ServersList from './ServersList'
const meta: Meta<typeof ServersList> = {
component: ServersList,
render (args) {
const [addOpen, setAddOpen] = useState(false)
const [username, setUsername] = useState('')
return addOpen ?
<AddServerOrConnect
onBack={() => {
setAddOpen(false)
}}
accounts={['testting']}
onConfirm={(info) => {
console.log('add server', info)
}}
/> :
<ServersList
worldData={[{
name: 'test',
title: 'Server',
formattedTextOverride: 'play yes',
}]}
joinServer={(ip) => {
console.log('joinServer', ip)
}}
initialProxies={{
proxies: ['localhost', 'mc.hypixel.net'],
selected: 'localhost',
}}
updateProxies={newData => {
console.log('setProxies', newData)
}}
onWorldAction={() => { }}
onGeneralAction={(action) => {
if (action === 'create') {
setAddOpen(true)
}
}}
username={username}
setUsername={setUsername}
/>
},
}
export default meta
type Story = StoryObj<typeof ServersList>
export const Primary: Story = {
args: {
},
}

View file

@ -86,6 +86,35 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const [serversList, setServersList] = useState<StoreServerItem[]>(() => (customServersList ? [] : getInitialServersList()))
const [additionalData, setAdditionalData] = useState<Record<string, AdditionalDisplayData>>({})
// Add keyboard handler for moving servers
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (['input', 'textarea', 'select'].includes((e.target as HTMLElement)?.tagName?.toLowerCase())) return
if (!e.shiftKey || selectedIndex === undefined) return
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
e.stopImmediatePropagation()
const newIndex = e.key === 'ArrowUp'
? Math.max(0, selectedIndex - 1)
: Math.min(serversList.length - 1, selectedIndex + 1)
if (newIndex === selectedIndex) return
// Move server in the list
const newList = [...serversList]
const oldItem = newList[selectedIndex]
newList[selectedIndex] = newList[newIndex]
newList[newIndex] = oldItem
setServersList(newList)
setSelectedIndex(newIndex)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [selectedIndex, serversList])
useEffect(() => {
if (customServersList) {
setServersList(customServersList.map(row => {
@ -105,10 +134,11 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
setNewServersList(serversList)
}, [serversList])
const serversListSorted = useMemo(() => serversList.map((server, index) => ({ ...server, index })), [serversList])
// by lastJoined
const serversListSorted = useMemo(() => {
return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0))
}, [serversList])
// const serversListSorted = useMemo(() => {
// return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0))
// }, [serversList])
const isEditScreenModal = useIsModalActive('editServer')
@ -313,6 +343,13 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
// find and update
const server = serversList.find(s => s.ip === ip)
if (server) {
// move to top
const newList = [...serversList]
const index = newList.indexOf(server)
const thisItem = newList[index]
newList.splice(index, 1)
newList.unshift(thisItem)
server.lastJoined = Date.now()
server.numConnects = (server.numConnects || 0) + 1
setNewServersList(serversList)
@ -369,7 +406,8 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
worldNameRight: additional?.textNameRight ?? '',
worldNameRightGrayed: additional?.textNameRightGrayed ?? '',
iconSrc: additional?.icon,
offline: additional?.offline
offline: additional?.offline,
group: 'Custom Servers'
}
})}
initialProxies={{

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'
import { ProseMirrorView } from './prosemirror-markdown'
import SignEditor from './SignEditor'
@ -19,7 +20,7 @@ type Story = StoryObj<typeof SignEditor>
export const Primary: Story = {
args: {
handleInput () {},
isWysiwyg: false
ProseMirrorView
},
parameters: {
noScaling: true

View file

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { focusable } from 'tabbable'
import markdownToFormattedText from '../markdownToFormattedText'
import { ProseMirrorView } from './prosemirror-markdown'
import type { ProseMirrorView } from './prosemirror-markdown'
import Button from './Button'
import 'prosemirror-view/style/prosemirror.css'
import 'prosemirror-menu/style/menu.css'
@ -12,7 +12,7 @@ const imageSource = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAMCAYAA
type Props = {
handleInput: (target: HTMLInputElement) => void,
isWysiwyg: boolean,
ProseMirrorView: typeof ProseMirrorView,
handleClick?: (view: ResultType) => void
}
@ -22,7 +22,8 @@ export type ResultType = {
dataText: string[]
}
export default ({ handleInput, isWysiwyg, handleClick }: Props) => {
export default ({ handleInput, ProseMirrorView, handleClick }: Props) => {
const isWysiwyg = !!ProseMirrorView
const prosemirrorContainer = useRef(null)
const editorView = useRef<ProseMirrorView | null>(null)

View file

@ -22,6 +22,7 @@ export default () => {
const text = useRef<string[]>(['', '', '', ''])
const [enableWysiwyg, setEnableWysiwyg] = useState(false)
const isModalActive = useIsModalActive('signs-editor-screen')
const [proseMirrorView, setProseMirrorView] = useState(null as any)
const handleClick = (result: ResultType) => {
hideModal({ reactType: 'signs-editor-screen' })
@ -72,8 +73,14 @@ export default () => {
setEnableWysiwyg(false)
}
})
if (!process.env.SINGLE_FILE_BUILD) {
void import('./prosemirror-markdown').then(({ ProseMirrorView }) => {
setProseMirrorView(() => ProseMirrorView)
})
}
}, [])
if (!isModalActive) return null
return <SignEditor isWysiwyg={enableWysiwyg} handleInput={handleInput} handleClick={handleClick} />
return <SignEditor ProseMirrorView={enableWysiwyg ? proseMirrorView : undefined} handleInput={handleInput} handleClick={handleClick} />
}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
// todo optimize size
import missingWorldPreview from 'mc-assets/dist/other-textures/latest/gui/presets/isles.png'
@ -29,6 +29,21 @@ export interface WorldProps {
onInteraction?(interaction: 'enter' | 'space')
elemRef?: React.Ref<HTMLDivElement>
offline?: boolean
group?: string
}
const GroupHeader = ({ name, count, expanded, onToggle }: { name: string, count: number, expanded: boolean, onToggle: () => void }) => {
return <div
className={styles.world_root}
style={{ background: 'none', cursor: 'pointer', height: 'auto', fontSize: '8px' }}
onClick={onToggle}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#bcbcbc' }}>
<span>{expanded ? '▼' : '▶'}</span>
<span>{name}</span>
<span>({count})</span>
</div>
</div>
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, worldNameRightGrayed, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
@ -159,6 +174,7 @@ export default ({
const [search, setSearch] = useState('')
const [focusedWorld, setFocusedWorld] = useState(defaultSelectedRow === undefined ? '' : worldData?.[defaultSelectedRow]?.name ?? '')
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
useEffect(() => {
setFocusedWorld('')
@ -179,6 +195,13 @@ export default ({
}
const isSmallWidth = useIsSmallWidth()
const toggleGroup = (groupName: string) => {
setExpandedGroups(prev => ({
...prev,
[groupName]: prev[groupName] === undefined ? false : !prev[groupName]
}))
}
return <div ref={containerRef} hidden={hidden}>
<div className="dirt-bg" />
<div className={classNames('fullscreen', styles.root)}>
@ -214,22 +237,42 @@ export default ({
}
{
worldData
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, size, detail, ...rest }, index) => (
<World
{...rest}
size={size}
name={name}
elemRef={el => { worldRefs.current[name] = el }}
onFocus={row => onRowSelectHandler(row, index)}
isFocused={focusedWorld === name}
key={name}
onInteraction={(interaction) => {
if (interaction === 'enter') onWorldAction('load', name)
else if (interaction === 'space') firstButton.current?.focus()
}}
detail={detail}
/>
))
? (() => {
const filtered = worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase()))
const groups = filtered.reduce<Record<string, WorldProps[]>>((acc, world) => {
const group = world.group || ''
if (!acc[group]) acc[group] = []
acc[group].push(world)
return acc
}, {})
return Object.entries(groups).map(([groupName, worlds]) => (
<React.Fragment key={groupName}>
<GroupHeader
name={groupName}
count={worlds.length}
expanded={expandedGroups[groupName] ?? true}
onToggle={() => toggleGroup(groupName)}
/>
{(expandedGroups[groupName] ?? true) && worlds.map(({ name, size, detail, ...rest }, index) => (
<World
{...rest}
size={size}
name={name}
elemRef={el => { worldRefs.current[name] = el }}
onFocus={row => onRowSelectHandler(row, index)}
isFocused={focusedWorld === name}
key={name}
onInteraction={(interaction) => {
if (interaction === 'enter') onWorldAction('load', name)
else if (interaction === 'space') firstButton.current?.focus()
}}
detail={detail}
/>
))}
</React.Fragment>
))
})()
: <div style={{
fontSize: 10,
color: error ? 'red' : 'lightgray',

View file

@ -91,6 +91,7 @@ export const readWorlds = (abortController: AbortController) => {
iconSrc: iconBase64 ? `data:image/png;base64,${iconBase64}` : undefined,
size,
lastModified: levelDatStat.mtimeMs,
group: 'IndexedDB Memory Worlds'
} satisfies WorldProps & { lastModified?: number }
}))).filter((x, i) => {
if (x.status === 'rejected') {
@ -283,10 +284,10 @@ const Inner = () => {
return <Singleplayer
error={error}
isReadonly={selectedProvider === 'google' && (googleDriveReadonly || !isGoogleProviderReady || !selectedGoogleId)}
providers={{
local: 'Local',
google: 'Google Drive',
}}
// providers={{
// local: 'Local',
// google: 'Google Drive',
// }}
disabledProviders={[...isGoogleDriveAvailable() ? [] : ['google']]}
worldData={worlds}
providerActions={providerActions}

View file

@ -2,8 +2,8 @@ import { isCypress } from './standaloneUtils'
// might not resolve at all
export const registerServiceWorker = async () => {
if (!('serviceWorker' in navigator) || process.env.SINGLE_FILE_BUILD) return
if (process.env.DISABLE_SERVICE_WORKER) return
if (!('serviceWorker' in navigator)) return
if (!isCypress() && process.env.NODE_ENV !== 'development') {
return new Promise<void>(resolve => {
window.addEventListener('load', async () => {

View file

@ -1,6 +1,6 @@
import { versionToNumber } from 'renderer/viewer/prepare/utils'
import { toMajorVersion } from 'renderer/viewer/lib/simpleUtils'
import JsonOptimizer from '../optimizeJson'
import { restoreMinecraftData } from '../optimizeJson'
// import minecraftInitialDataJson from '../../generated/minecraft-initial-data.json'
const customResolver = () => {
@ -22,7 +22,7 @@ const optimizedDataResolver = customResolver()
window._MC_DATA_RESOLVER = optimizedDataResolver
window._LOAD_MC_DATA = async () => {
if (optimizedDataResolver.resolvedData) return
optimizedDataResolver.resolve(await import('../../generated/minecraft-data-optimized.json'))
optimizedDataResolver.resolve(await importLargeData('mcData'))
}
// 30 seconds
@ -53,7 +53,7 @@ const possiblyGetFromCache = (version: string) => {
const data = optimizedDataResolver.resolvedData[dataType]
if (data.__IS_OPTIMIZED__) {
allRestored[dataType] = JsonOptimizer.restoreData(data, version)
allRestored[dataType] = restoreMinecraftData(optimizedDataResolver.resolvedData, dataType, version)
} else {
allRestored[dataType] = data[version] ?? data[toMajorVersion(version)]
}