278 lines
9.5 KiB
TypeScript
278 lines
9.5 KiB
TypeScript
import { join, dirname } from 'path'
|
|
import fs from 'fs'
|
|
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 './inventoryWindows'
|
|
import { copyFilesAsync, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs'
|
|
import { setLoadingScreenStatus } from './utils'
|
|
import { showNotification } from './react/NotificationProvider'
|
|
|
|
export const resourcePackState = proxy({
|
|
resourcePackInstalled: false,
|
|
currentTexturesDataUrl: undefined as string | undefined,
|
|
currentTexturesBlockStates: undefined as BlockStates | undefined,
|
|
})
|
|
|
|
function nextPowerOfTwo (n) {
|
|
if (n === 0) return 1
|
|
n--
|
|
n |= n >> 1
|
|
n |= n >> 2
|
|
n |= n >> 4
|
|
n |= n >> 8
|
|
n |= n >> 16
|
|
return n + 1
|
|
}
|
|
|
|
const texturePackBasePath = '/data/resourcePacks/default'
|
|
export const uninstallTexturePack = async () => {
|
|
await removeFileRecursiveAsync(texturePackBasePath)
|
|
setCustomTexturePackData(undefined, undefined)
|
|
}
|
|
|
|
export const getResourcePackName = async () => {
|
|
// temp
|
|
try {
|
|
return await fs.promises.readFile(join(texturePackBasePath, 'name.txt'), 'utf8')
|
|
} catch (err) {
|
|
return '???'
|
|
}
|
|
}
|
|
|
|
export const fromTexturePackPath = (path) => {
|
|
return join(texturePackBasePath, path)
|
|
}
|
|
|
|
export const updateTexturePackInstalledState = async () => {
|
|
try {
|
|
resourcePackState.resourcePackInstalled = await existsAsync(texturePackBasePath)
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
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()
|
|
} catch (err) {
|
|
}
|
|
const status = 'Installing resource pack: copying all files'
|
|
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(texturePackBasePath)
|
|
|
|
const allFilesArr = Object.entries(zipFile.files)
|
|
let done = 0
|
|
const upStatus = () => {
|
|
setLoadingScreenStatus(`${status} ${Math.round(done / allFilesArr.length * 100)}%`)
|
|
}
|
|
await Promise.all(allFilesArr.map(async ([path, file]) => {
|
|
const writePath = join(texturePackBasePath, path)
|
|
if (path.endsWith('/')) return
|
|
await mkdirRecursive(dirname(writePath))
|
|
await fs.promises.writeFile(writePath, Buffer.from(await file.async('arraybuffer')))
|
|
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) {
|
|
await genTexturePackTextures(viewer.world.version!)
|
|
}
|
|
setLoadingScreenStatus(undefined)
|
|
showNotification('Texturepack installed')
|
|
await updateTexturePackInstalledState()
|
|
}
|
|
|
|
const existsAsync = async (path) => {
|
|
try {
|
|
await fs.promises.stat(path)
|
|
return true
|
|
} catch (err) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
type TextureResolvedData = {
|
|
blockSize: number
|
|
// itemsUrlContent: string
|
|
}
|
|
|
|
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/${getVersion(version)}.json`)
|
|
const blockStates: BlockStates = await result.json()
|
|
const factor = blockSize / 16
|
|
|
|
// this will be refactored with generateTextures refactor
|
|
const processObj = (x) => {
|
|
if (typeof x !== 'object' || !x) return
|
|
if (Array.isArray(x)) {
|
|
for (const v of x) {
|
|
processObj(v)
|
|
}
|
|
|
|
} else {
|
|
const actual = Object.keys(x)
|
|
const needed = ['u', 'v', 'su', 'sv']
|
|
|
|
if (!arrEqual(actual, needed)) {
|
|
for (const v of Object.values(x)) {
|
|
processObj(v)
|
|
}
|
|
return
|
|
}
|
|
for (const k of needed) {
|
|
x[k] *= factor
|
|
}
|
|
}
|
|
}
|
|
processObj(blockStates)
|
|
setCustomTexturePackData(blocksUrlContent, blockStates)
|
|
}
|
|
|
|
const setCustomTexturePackData = (blockTextures, blockStates) => {
|
|
resourcePackState.currentTexturesBlockStates = blockStates && ref(blockStates)
|
|
resourcePackState.currentTexturesDataUrl = blockTextures
|
|
resourcePackState.resourcePackInstalled = blockTextures !== undefined
|
|
}
|
|
|
|
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 genTexturePackTextures = async (version: string) => {
|
|
setCustomTexturePackData(undefined, undefined)
|
|
let blocksBasePath = '/data/resourcePacks/default/assets/minecraft/textures/block'
|
|
// todo not clear why this is needed
|
|
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
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
if (await existsAsync(blocksGeneratedPath)) {
|
|
// make sure we await it, so we set properties in world renderer and it won't try to load default textures
|
|
await applyTexturePackData(version, JSON.parse(await fs.promises.readFile(generatedPathData, 'utf8')), await fs.promises.readFile(blocksGeneratedPath, 'utf8'))
|
|
return
|
|
}
|
|
|
|
setLoadingScreenStatus('Generating custom textures')
|
|
|
|
const textureFiles = blocksFileNames.indexes[version].map(k => blocksFileNames.blockNames[k])
|
|
textureFiles.unshift('missing_texture.png')
|
|
|
|
const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length)))
|
|
const originalTileSize = 16
|
|
|
|
const firstBlockFile = (await fs.promises.readdir(blocksBasePath)).find(f => f.endsWith('.png'))
|
|
if (!firstBlockFile) {
|
|
return
|
|
}
|
|
|
|
// we get the size of image from the first block file, which is not ideal but works in 99% cases
|
|
const tileSize = Math.max(originalTileSize, await getSizeFromImage(join(blocksBasePath, firstBlockFile)))
|
|
|
|
const imgSize = texSize * tileSize
|
|
|
|
const MAX_CANVAS_SIZE = 16_384
|
|
if (imgSize > MAX_CANVAS_SIZE) {
|
|
throw new Error(`Texture pack texture resolution is too big, max size is ${MAX_CANVAS_SIZE}x${MAX_CANVAS_SIZE}`)
|
|
// texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length / 2)))
|
|
}
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = imgSize
|
|
canvas.height = imgSize
|
|
const src = `textures/${getVersion(version)}.png`
|
|
const ctx = canvas.getContext('2d')!
|
|
ctx.imageSmoothingEnabled = false
|
|
const img = new Image()
|
|
img.src = src
|
|
await new Promise((resolve, reject) => {
|
|
img.onerror = reject
|
|
img.addEventListener('load', resolve)
|
|
})
|
|
for (const [i, fileName] of textureFiles.entries()) {
|
|
const x = (i % texSize) * tileSize
|
|
const y = Math.floor(i / texSize) * tileSize
|
|
const xOrig = (i % texSize) * originalTileSize
|
|
const yOrig = Math.floor(i / texSize) * originalTileSize
|
|
let imgCustom!: HTMLImageElement
|
|
try {
|
|
const fileBase64 = await fs.promises.readFile(join(blocksBasePath, fileName), 'base64')
|
|
const _imgCustom = new Image()
|
|
// I think it can crash otherwise
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await new Promise<void>(resolve => {
|
|
_imgCustom.addEventListener('load', () => {
|
|
imgCustom = _imgCustom
|
|
resolve()
|
|
})
|
|
_imgCustom.onerror = () => {
|
|
console.log('Skipping issued texture', fileName)
|
|
resolve()
|
|
}
|
|
_imgCustom.src = `data:image/png;base64,${fileBase64}`
|
|
})
|
|
} catch {
|
|
console.log('Skipping not found texture', fileName)
|
|
}
|
|
|
|
if (imgCustom) {
|
|
ctx.drawImage(imgCustom, x, y, tileSize, tileSize)
|
|
} else {
|
|
// todo this involves incorrect mappings for existing textures when the size is different
|
|
ctx.drawImage(img, xOrig, yOrig, originalTileSize, originalTileSize, x, y, tileSize, tileSize)
|
|
}
|
|
}
|
|
const blockDataUrl = canvas.toDataURL('image/png')
|
|
const newData: TextureResolvedData = {
|
|
blockSize: tileSize,
|
|
}
|
|
await fs.promises.writeFile(generatedPathData, JSON.stringify(newData), 'utf8')
|
|
await fs.promises.writeFile(blocksGeneratedPath, blockDataUrl, 'utf8')
|
|
await applyTexturePackData(version, newData, blockDataUrl)
|
|
|
|
// const a = document.createElement('a')
|
|
// a.href = dataUrl
|
|
// a.download = 'pack.png'
|
|
// a.click()
|
|
}
|
|
|
|
export const watchTexturepackInViewer = (viewer: Viewer) => {
|
|
subscribeKey(resourcePackState, 'currentTexturesDataUrl', () => {
|
|
console.log('applying resourcepack world data')
|
|
viewer.world.customTexturesDataUrl = resourcePackState.currentTexturesDataUrl
|
|
viewer.world.customBlockStatesData = resourcePackState.currentTexturesBlockStates
|
|
if (!viewer?.world.active) return
|
|
viewer.world.updateTexturesData()
|
|
})
|
|
}
|