506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
/* eslint-disable no-await-in-loop */
|
|
import { join, dirname, basename } from 'path'
|
|
import fs from 'fs'
|
|
import JSZip from 'jszip'
|
|
import { proxy, subscribe } from 'valtio'
|
|
import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree'
|
|
import { collectFilesToCopy, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs'
|
|
import { setLoadingScreenStatus } from './utils'
|
|
import { showNotification } from './react/NotificationProvider'
|
|
import { options } from './optionsStorage'
|
|
import { showOptionsModal } from './react/SelectOption'
|
|
import { appStatusState } from './react/AppStatusProvider'
|
|
import { appReplacableResources, resourcesContentOriginal } from './generated/resources'
|
|
import { gameAdditionalState, miscUiState } from './globalState'
|
|
import { watchUnloadForCleanup } from './gameUnload'
|
|
|
|
export const resourcePackState = proxy({
|
|
resourcePackInstalled: false,
|
|
isServerDownloading: false,
|
|
isServerInstalling: false
|
|
})
|
|
|
|
const getLoadedImage = async (url: string) => {
|
|
const img = new Image()
|
|
img.src = url
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve
|
|
img.onerror = reject
|
|
})
|
|
return img
|
|
}
|
|
|
|
const texturePackBasePath = '/data/resourcePacks/'
|
|
export const uninstallTexturePack = async (name = 'default') => {
|
|
if (await existsAsync('/resourcepack/pack.mcmeta')) {
|
|
await removeFileRecursiveAsync('/resourcepack')
|
|
gameAdditionalState.usingServerResourcePack = false
|
|
}
|
|
const basePath = texturePackBasePath + name
|
|
if (!(await existsAsync(basePath))) return
|
|
await removeFileRecursiveAsync(basePath)
|
|
options.enabledResourcepack = null
|
|
await updateTexturePackInstalledState()
|
|
}
|
|
|
|
export const getResourcePackNames = async () => {
|
|
// TODO
|
|
try {
|
|
return { [await fs.promises.readFile(join(texturePackBasePath, 'default', 'name.txt'), 'utf8')]: true }
|
|
} catch (err) {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
export const fromTexturePackPath = (path) => {
|
|
// return join(texturePackBasePath, path)
|
|
}
|
|
|
|
export const updateTexturePackInstalledState = async () => {
|
|
try {
|
|
resourcePackState.resourcePackInstalled = await existsAsync(texturePackBasePath + 'default')
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
export const installTexturePackFromHandle = async () => {
|
|
// await mkdirRecursive(texturePackBasePath)
|
|
// await copyFilesAsyncWithProgress('/world', texturePackBasePath)
|
|
// await completeTexturePackInstall()
|
|
}
|
|
|
|
export const installTexturePack = async (file: File | ArrayBuffer, displayName = file['name'], name = 'default', isServer = false) => {
|
|
const installPath = isServer ? '/resourcepack/' : texturePackBasePath + name
|
|
try {
|
|
await uninstallTexturePack(name)
|
|
} catch (err) {
|
|
}
|
|
const showLoader = !isServer
|
|
const status = 'Installing resource pack: copying all files'
|
|
|
|
if (showLoader) {
|
|
setLoadingScreenStatus(status)
|
|
}
|
|
// extract the zip and write to fs every file in it
|
|
const zip = new JSZip()
|
|
const zipFile = await zip.loadAsync(file)
|
|
if (!zipFile.file('pack.mcmeta')) throw new Error('Not a resource pack: missing /pack.mcmeta')
|
|
await mkdirRecursive(installPath)
|
|
|
|
const allFilesArr = Object.entries(zipFile.files)
|
|
.filter(([path]) => !path.startsWith('.') && !path.startsWith('_') && !path.startsWith('/')) // ignore dot files and __MACOSX
|
|
let done = 0
|
|
const upStatus = () => {
|
|
if (showLoader) {
|
|
setLoadingScreenStatus(`${status} ${Math.round(done / allFilesArr.length * 100)}%`)
|
|
}
|
|
}
|
|
const createdDirs = new Set<string>()
|
|
const copyTasks = [] as Array<Promise<void>>
|
|
await Promise.all(allFilesArr.map(async ([path, file]) => {
|
|
const writePath = join(installPath, path)
|
|
if (path.endsWith('/')) return
|
|
const dir = dirname(writePath)
|
|
if (!createdDirs.has(dir)) {
|
|
await mkdirRecursive(dir)
|
|
createdDirs.add(dir)
|
|
}
|
|
if (copyTasks.length > 100) {
|
|
await Promise.all(copyTasks)
|
|
copyTasks.length = 0
|
|
}
|
|
const promise = fs.promises.writeFile(writePath, Buffer.from(await file.async('arraybuffer')) as any)
|
|
copyTasks.push(promise)
|
|
await promise
|
|
done++
|
|
upStatus()
|
|
}))
|
|
console.log('resource pack install done')
|
|
await completeTexturePackInstall(displayName, name, isServer)
|
|
}
|
|
|
|
// or enablement
|
|
export const completeTexturePackInstall = async (displayName: string | undefined, name: string, isServer: boolean) => {
|
|
const basePath = isServer ? '/resourcepack/' : texturePackBasePath + name
|
|
if (displayName) {
|
|
await fs.promises.writeFile(join(basePath, 'name.txt'), displayName, 'utf8')
|
|
}
|
|
|
|
await updateTextures()
|
|
setLoadingScreenStatus(undefined)
|
|
showNotification('Texturepack installed & enabled')
|
|
await updateTexturePackInstalledState()
|
|
if (isServer) {
|
|
gameAdditionalState.usingServerResourcePack = true
|
|
} else {
|
|
options.enabledResourcepack = name
|
|
}
|
|
}
|
|
|
|
const existsAsync = async (path) => {
|
|
try {
|
|
await fs.promises.stat(path)
|
|
return true
|
|
} catch (err) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const arrEqual = (a: any[], b: any[]) => a.length === b.length && a.every((x) => b.includes(x))
|
|
|
|
const getSizeFromImage = async (filePath: string) => {
|
|
const probeImg = new Image()
|
|
const file = await fs.promises.readFile(filePath, 'base64')
|
|
probeImg.src = `data:image/png;base64,${file}`
|
|
await new Promise((resolve, reject) => {
|
|
probeImg.addEventListener('load', resolve)
|
|
})
|
|
if (probeImg.width !== probeImg.height) throw new Error(`Probe texture ${filePath} is not square`)
|
|
return probeImg.width
|
|
}
|
|
|
|
export const getActiveResourcepackBasePath = async () => {
|
|
if (await existsAsync('/resourcepack/pack.mcmeta')) {
|
|
return '/resourcepack'
|
|
}
|
|
const { enabledResourcepack } = options
|
|
// const enabledResourcepack = 'default'
|
|
if (!enabledResourcepack) {
|
|
return null
|
|
}
|
|
if (await existsAsync(`/data/resourcePacks/${enabledResourcepack}/pack.mcmeta`)) {
|
|
return `/data/resourcePacks/${enabledResourcepack}`
|
|
}
|
|
return null
|
|
}
|
|
|
|
const isDirSafe = async (filePath: string) => {
|
|
try {
|
|
return await fs.promises.stat(filePath).then(stat => stat.isDirectory()).catch(() => false)
|
|
} catch (err) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const getFilesMapFromDir = async (dir: string) => {
|
|
const files = [] as string[]
|
|
const scan = async (dir) => {
|
|
const dirFiles = await fs.promises.readdir(dir)
|
|
for (const file of dirFiles) {
|
|
const filePath = join(dir, file)
|
|
if (await isDirSafe(filePath)) {
|
|
await scan(filePath)
|
|
} else {
|
|
files.push(filePath)
|
|
}
|
|
}
|
|
}
|
|
await scan(dir)
|
|
return files
|
|
}
|
|
|
|
export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTextures: string[]) => {
|
|
const basePath = await getActiveResourcepackBasePath()
|
|
if (!basePath) return
|
|
let firstTextureSize: number | undefined
|
|
const namespaces = await fs.promises.readdir(join(basePath, 'assets'))
|
|
if (appStatusState.status) {
|
|
setLoadingScreenStatus(`Generating atlas texture for ${type}`)
|
|
}
|
|
const textures = {} as Record<string, HTMLImageElement>
|
|
for (const namespace of namespaces) {
|
|
const texturesCommonBasePath = `${basePath}/assets/${namespace}/textures`
|
|
const isMinecraftNamespace = namespace === 'minecraft'
|
|
let texturesBasePath = `${texturesCommonBasePath}/${type === 'blocks' ? 'block' : 'item'}`
|
|
const texturesBasePathAlt = `${texturesCommonBasePath}/${type === 'blocks' ? 'blocks' : 'items'}`
|
|
if (!(await existsAsync(texturesBasePath))) {
|
|
if (await existsAsync(texturesBasePathAlt)) {
|
|
texturesBasePath = texturesBasePathAlt
|
|
}
|
|
}
|
|
const allInterestedPaths = new Set(
|
|
existingTextures
|
|
.filter(tex => (isMinecraftNamespace && !tex.includes(':')) || (tex.includes(':') && tex.split(':')[0] === namespace))
|
|
.map(tex => {
|
|
tex = tex.split(':')[1] ?? tex
|
|
if (tex.includes('/')) {
|
|
return join(`${texturesCommonBasePath}/${tex}`)
|
|
}
|
|
return join(texturesBasePath, tex)
|
|
})
|
|
)
|
|
// add all files from texturesCommonBasePath
|
|
// if (!isMinecraftNamespace) {
|
|
// const commonBasePathFiles = await getFilesMapFromDir(texturesCommonBasePath)
|
|
// for (const file of commonBasePathFiles) {
|
|
// allInterestedPaths.add(file)
|
|
// }
|
|
// }
|
|
const allInterestedPathsPerDir = new Map<string, string[]>()
|
|
for (const path of allInterestedPaths) {
|
|
const dir = dirname(path)
|
|
if (!allInterestedPathsPerDir.has(dir)) {
|
|
allInterestedPathsPerDir.set(dir, [])
|
|
}
|
|
const file = basename(path)
|
|
allInterestedPathsPerDir.get(dir)!.push(file)
|
|
}
|
|
// filter out by readdir each dir
|
|
const allInterestedImages = [] as string[]
|
|
for (const [dir, paths] of allInterestedPathsPerDir) {
|
|
if (!await existsAsync(dir)) {
|
|
continue
|
|
}
|
|
const dirImages = (await fs.promises.readdir(dir)).filter(f => f.endsWith('.png')).map(f => f.replace('.png', ''))
|
|
allInterestedImages.push(...dirImages.filter(image => paths.includes(image)).map(image => `${dir}/${image}`))
|
|
}
|
|
|
|
if (allInterestedImages.length === 0) {
|
|
continue
|
|
}
|
|
|
|
const firstImageFile = allInterestedImages[0]!
|
|
try {
|
|
// todo check all sizes from atlas
|
|
firstTextureSize ??= await getSizeFromImage(`${firstImageFile}.png`)
|
|
} catch (err) { }
|
|
const newTextures = Object.fromEntries(await Promise.all(allInterestedImages.map(async (image) => {
|
|
const imagePath = `${image}.png`
|
|
const contents = await fs.promises.readFile(imagePath, 'base64')
|
|
const img = await getLoadedImage(`data:image/png;base64,${contents}`)
|
|
const imageRelative = image.replace(`${texturesBasePath}/`, '').replace(`${texturesCommonBasePath}/`, '')
|
|
const textureName = isMinecraftNamespace ? imageRelative : `${namespace}:${imageRelative}`
|
|
return [textureName, img]
|
|
})))
|
|
Object.assign(textures, newTextures) as any
|
|
}
|
|
return {
|
|
firstTextureSize,
|
|
textures
|
|
}
|
|
}
|
|
|
|
const prepareBlockstatesAndModels = async () => {
|
|
viewer.world.customBlockStates = {}
|
|
viewer.world.customModels = {}
|
|
const usedTextures = new Set<string>()
|
|
const basePath = await getActiveResourcepackBasePath()
|
|
if (!basePath) return
|
|
if (appStatusState.status) {
|
|
setLoadingScreenStatus('Reading resource pack blockstates and models')
|
|
}
|
|
const readData = async (namespaceDir: string) => {
|
|
const blockstatesPath = `${basePath}/assets/${namespaceDir}/blockstates`
|
|
const modelsPath = `${basePath}/assets/${namespaceDir}/models/block` // todo also models/item
|
|
const getAllJson = async (path: string, type: 'models' | 'blockstates') => {
|
|
if (!(await existsAsync(path))) return
|
|
const files = await fs.promises.readdir(path)
|
|
const jsons = {} as Record<string, any>
|
|
await Promise.all(files.map(async (file) => {
|
|
const filePath = `${path}/${file}`
|
|
if (file.endsWith('.json')) {
|
|
const contents = await fs.promises.readFile(filePath, 'utf8')
|
|
let name = file.replace('.json', '')
|
|
if (type === 'models') {
|
|
name = `block/${name}`
|
|
}
|
|
const parsed = JSON.parse(contents)
|
|
if (namespaceDir === 'minecraft') {
|
|
jsons[name] = parsed
|
|
}
|
|
jsons[`${namespaceDir}:${name}`] = parsed
|
|
if (type === 'models') {
|
|
for (let texturePath of Object.values(parsed.textures ?? {})) {
|
|
if (typeof texturePath !== 'string') continue
|
|
if (texturePath.startsWith('#')) continue
|
|
if (!texturePath.includes(':')) texturePath = `minecraft:${texturePath}`
|
|
usedTextures.add(texturePath as string)
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
return jsons
|
|
}
|
|
Object.assign(viewer.world.customBlockStates!, await getAllJson(blockstatesPath, 'blockstates'))
|
|
Object.assign(viewer.world.customModels!, await getAllJson(modelsPath, 'models'))
|
|
}
|
|
try {
|
|
const assetsDirs = await fs.promises.readdir(join(basePath, 'assets'))
|
|
for (const assetsDir of assetsDirs) {
|
|
await readData(assetsDir)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to read some of resource pack blockstates and models', err)
|
|
viewer.world.customBlockStates = undefined
|
|
viewer.world.customModels = undefined
|
|
}
|
|
return { usedTextures }
|
|
}
|
|
|
|
const downloadAndUseResourcePack = async (url: string): Promise<void> => {
|
|
try {
|
|
resourcePackState.isServerInstalling = true
|
|
resourcePackState.isServerDownloading = true
|
|
console.log('Downloading server resource pack', url)
|
|
const response = await fetch(url).catch((err) => {
|
|
console.log(`Ensure server on ${url} support CORS which is not required for regular client, but is required for the web client`)
|
|
console.error(err)
|
|
showNotification('Failed to download resource pack: ' + err.message)
|
|
})
|
|
if (!response) return
|
|
resourcePackState.isServerDownloading = false
|
|
const resourcePackData = await response.arrayBuffer()
|
|
showNotification('Installing resource pack...')
|
|
await installTexturePack(resourcePackData, undefined, undefined, true).catch((err) => {
|
|
console.error(err)
|
|
showNotification('Failed to install resource pack: ' + err.message)
|
|
})
|
|
} finally {
|
|
resourcePackState.isServerInstalling = false
|
|
resourcePackState.isServerDownloading = false
|
|
}
|
|
}
|
|
|
|
const waitForGameEvent = async () => {
|
|
if (miscUiState.gameLoaded) return
|
|
await new Promise<void>(resolve => {
|
|
const listener = () => resolve()
|
|
customEvents.once('gameLoaded', listener)
|
|
watchUnloadForCleanup(() => {
|
|
customEvents.removeListener('gameLoaded', listener)
|
|
})
|
|
})
|
|
}
|
|
|
|
export const onAppLoad = () => {
|
|
customEvents.on('mineflayerBotCreated', () => {
|
|
// todo also handle resourcePack
|
|
const handleResourcePackRequest = async (packet) => {
|
|
console.log('Received resource pack request', packet)
|
|
if (options.serverResourcePacks === 'never') return
|
|
const promptMessagePacket = ('promptMessage' in packet && packet.promptMessage) ? packet.promptMessage : undefined
|
|
const promptMessageText = promptMessagePacket ? '' : 'Do you want to use server resource pack?'
|
|
// TODO!
|
|
const hash = 'hash' in packet ? packet.hash : '-'
|
|
const forced = 'forced' in packet ? packet.forced : false
|
|
const choice = options.serverResourcePacks === 'always'
|
|
? true
|
|
: await showOptionsModal(promptMessageText, ['Download & Install (recommended)', 'Pretend Installed (not recommended)'], {
|
|
cancel: !forced,
|
|
minecraftJsonMessage: promptMessagePacket,
|
|
})
|
|
if (!choice) return
|
|
await new Promise(resolve => {
|
|
setTimeout(resolve, 500)
|
|
})
|
|
console.log('accepting resource pack')
|
|
bot.acceptResourcePack()
|
|
if (choice === true || choice === 'Download & Install (recommended)') {
|
|
await downloadAndUseResourcePack(packet.url).catch((err) => {
|
|
console.error(err)
|
|
showNotification('Failed to download resource pack: ' + err.message)
|
|
})
|
|
}
|
|
}
|
|
bot._client.on('resource_pack_send', handleResourcePackRequest)
|
|
bot._client.on('add_resource_pack' as any, handleResourcePackRequest)
|
|
})
|
|
|
|
subscribe(resourcePackState, () => {
|
|
if (!resourcePackState.resourcePackInstalled) return
|
|
void updateAllReplacableTextures()
|
|
})
|
|
}
|
|
|
|
const updateAllReplacableTextures = async () => {
|
|
const basePath = await getActiveResourcepackBasePath()
|
|
const setCustomCss = async (path: string | null, varName: string, repeat = 1) => {
|
|
if (path && await existsAsync(path)) {
|
|
const contents = await fs.promises.readFile(path, 'base64')
|
|
const dataUrl = `data:image/png;base64,${contents}`
|
|
document.body.style.setProperty(varName, repeatArr(`url(${dataUrl})`, repeat).join(', '))
|
|
} else {
|
|
document.body.style.setProperty(varName, '')
|
|
}
|
|
}
|
|
const setCustomPicture = async (key: string, path: string) => {
|
|
let contents = resourcesContentOriginal[key]
|
|
if (await existsAsync(path)) {
|
|
const file = await fs.promises.readFile(path, 'base64')
|
|
const dataUrl = `data:image/png;base64,${file}`
|
|
contents = dataUrl
|
|
}
|
|
appReplacableResources[key].content = contents
|
|
}
|
|
const vars = Object.entries(appReplacableResources).filter(([, x]) => x.cssVar)
|
|
for (const [key, { cssVar, cssVarRepeat, resourcePackPath }] of vars) {
|
|
const resPath = `${basePath}/assets/${resourcePackPath}`
|
|
if (cssVar) {
|
|
|
|
await setCustomCss(resPath, cssVar, cssVarRepeat ?? 1)
|
|
} else {
|
|
|
|
await setCustomPicture(key, resPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
const repeatArr = (arr, i) => Array.from({ length: i }, () => arr)
|
|
|
|
const updateTextures = async () => {
|
|
const blocksFiles = Object.keys(viewer.world.blocksAtlases.latest.textures)
|
|
const itemsFiles = Object.keys(viewer.world.itemsAtlases.latest.textures)
|
|
const { usedTextures: extraBlockTextures = new Set<string>() } = await prepareBlockstatesAndModels() ?? {}
|
|
const blocksData = await getResourcepackTiles('blocks', [...blocksFiles, ...extraBlockTextures])
|
|
const itemsData = await getResourcepackTiles('items', itemsFiles)
|
|
await updateAllReplacableTextures()
|
|
viewer.world.customTextures = {}
|
|
if (blocksData) {
|
|
viewer.world.customTextures.blocks = {
|
|
tileSize: blocksData.firstTextureSize,
|
|
textures: blocksData.textures
|
|
}
|
|
}
|
|
if (itemsData) {
|
|
viewer.world.customTextures.items = {
|
|
tileSize: itemsData.firstTextureSize,
|
|
textures: itemsData.textures
|
|
}
|
|
}
|
|
if (viewer.world.active) {
|
|
await viewer.world.updateTexturesData()
|
|
if (viewer.world instanceof WorldRendererThree) {
|
|
viewer.world.rerenderAllChunks?.()
|
|
}
|
|
}
|
|
}
|
|
|
|
export const resourcepackReload = async (version) => {
|
|
await updateTextures()
|
|
}
|
|
|
|
export const copyServerResourcePackToRegular = async (name = 'default') => {
|
|
// Check if server resource pack exists
|
|
if (!(await existsAsync('/resourcepack/pack.mcmeta'))) {
|
|
throw new Error('No server resource pack is currently installed')
|
|
}
|
|
|
|
// Get display name from server resource pack if available
|
|
let displayName
|
|
try {
|
|
displayName = await fs.promises.readFile('/resourcepack/name.txt', 'utf8')
|
|
} catch {
|
|
displayName = 'Server Resource Pack'
|
|
}
|
|
|
|
// Copy all files from server resource pack to regular location
|
|
const destPath = texturePackBasePath + name
|
|
await mkdirRecursive(destPath)
|
|
|
|
setLoadingScreenStatus('Copying server resource pack to regular location')
|
|
await copyFilesAsyncWithProgress('/resourcepack', destPath, true, ' (server -> regular)')
|
|
|
|
// Complete the installation
|
|
await completeTexturePackInstall(displayName, name, false)
|
|
showNotification('Server resource pack copied to regular location')
|
|
}
|