feat: new pretty accurate items in hotbar renderer! Almost all plain items are now rendered correctly (even between versions)
feat: now almost all containers are supported. Added support for crafting & chests fix: fix some rendering issues in inventory
This commit is contained in:
parent
249a4ce1f8
commit
7aa91d2f06
15 changed files with 504 additions and 144 deletions
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -14370,9 +14370,9 @@ 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/minecraft-inventory-gui/babb4669ac26b14ccf47505fb40d2590b6882c60(@types/react@18.2.20)(react@18.2.0):
|
||||
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/babb4669ac26b14ccf47505fb40d2590b6882c60}
|
||||
id: github.com/zardoy/minecraft-inventory-gui/babb4669ac26b14ccf47505fb40d2590b6882c60
|
||||
name: minecraft-inventory-gui
|
||||
version: 1.0.1
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -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,23 @@ 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
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
loadBlockStates().then((blockStates) => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function readTexture (basePath, name) {
|
|||
return fs.readFileSync(path.join(basePath, name), 'base64')
|
||||
}
|
||||
|
||||
type JsonAtlas = {
|
||||
export type JsonAtlas = {
|
||||
size: number,
|
||||
textures: {
|
||||
[file: string]: {
|
||||
|
|
@ -37,7 +37,7 @@ type JsonAtlas = {
|
|||
}
|
||||
}
|
||||
|
||||
export const makeTextureAtlas = (input: string[], getInputData: (name) => {contents: string, tileWidthMult?: number}, tilesCount = input.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
|
||||
|
|
@ -59,18 +59,34 @@ export const makeTextureAtlas = (input: string[], getInputData: (name) => {conte
|
|||
const y = Math.floor(pos / texSize) * tileSize
|
||||
|
||||
const img = new Image()
|
||||
const inputData = getInputData(input[i]);
|
||||
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)
|
||||
|
||||
const cleanName = input[i].split('.')[0]
|
||||
texturesIndex[cleanName] = { u: x / imgSize, v: y / imgSize, su: suSv, sv: suSv }
|
||||
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: 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'))
|
||||
|
|
|
|||
152
prismarine-viewer/viewer/prepare/genItemsAtlas.ts
Normal file
152
prismarine-viewer/viewer/prepare/genItemsAtlas.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//@ts-check
|
||||
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'
|
||||
|
||||
//@ts-ignore
|
||||
const Version = _Version
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
remapItems: {} as Record<string, string>, // 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)
|
||||
}
|
||||
}
|
||||
|
||||
export type ItemsAtlasesOutputJson = {
|
||||
latest: JsonAtlas
|
||||
legacy: JsonAtlas
|
||||
legacyMap: [string, string[]][]
|
||||
}
|
||||
|
||||
export const generateItemsAtlases = async () => {
|
||||
let fullItemsMap = {} as Record<string, string[]>
|
||||
|
||||
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)}`)
|
||||
}
|
||||
|
|
@ -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<string>()
|
||||
Promise.resolve().then(async () => {
|
||||
generateItemsAtlases()
|
||||
console.time('generateTextures')
|
||||
for (const version of mcAssets.versions as typeof mcAssets['versions']) {
|
||||
for (const version of ['1.14.4'] 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))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
4
prismarine-viewer/viewer/prepare/utils.ts
Normal file
4
prismarine-viewer/viewer/prepare/utils.ts
Normal file
|
|
@ -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')}`
|
||||
}
|
||||
|
|
@ -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 { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState } from './globalState'
|
||||
import { goFullscreen, pointerLock, reloadChunks } from './utils'
|
||||
import { options } from './optionsStorage'
|
||||
import { openPlayerInventory } from './inventory'
|
||||
|
||||
// doesnt seem to work for now
|
||||
const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}'))
|
||||
|
|
@ -167,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()
|
||||
}
|
||||
}
|
||||
|
|
@ -199,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)
|
||||
|
|
|
|||
23
src/index.ts
23
src/index.ts
|
|
@ -188,23 +188,19 @@ function hideCurrentScreens () {
|
|||
insertActiveModalStack('', [])
|
||||
}
|
||||
|
||||
async function main () {
|
||||
const connectSingleplayer = (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)
|
||||
})
|
||||
const qs = new URLSearchParams(window.location.search)
|
||||
if (qs.get('singleplayer') === '1') {
|
||||
connectSingleplayer()
|
||||
}
|
||||
}
|
||||
|
||||
let listeners = []
|
||||
|
|
@ -491,6 +487,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)
|
||||
|
||||
|
|
@ -707,8 +704,14 @@ async function connect (connectOptions: {
|
|||
})
|
||||
}
|
||||
|
||||
watchValue(miscUiState, m => {
|
||||
if (m.appLoaded) void main()
|
||||
listenGlobalEvents()
|
||||
watchValue(miscUiState, () => {
|
||||
if (miscUiState.appLoaded) { // fs ready
|
||||
const qs = new URLSearchParams(window.location.search)
|
||||
if (qs.get('singleplayer') === '1') {
|
||||
connectSingleplayer()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
downloadAndOpenFile().then((downloadAction) => {
|
||||
|
|
|
|||
335
src/inventory.ts
335
src/inventory.ts
|
|
@ -1,67 +1,105 @@
|
|||
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 { activeModalStack, hideCurrentModal, miscUiState } from './globalState'
|
||||
import { options } from './optionsStorage'
|
||||
|
||||
const loadedImages = new Map<string, HTMLImageElement>()
|
||||
const itemsAtlases: ItemsAtlasesOutputJson = _itemsAtlases
|
||||
const loadedImagesCache = new Map<string, HTMLImageElement>()
|
||||
const cleanLoadedImagesCache = () => {
|
||||
loadedImagesCache.delete('blocks')
|
||||
}
|
||||
export type BlockStates = Record<string, null | {
|
||||
variants: Record<string, Array<{
|
||||
variants: Record<string, {
|
||||
model: {
|
||||
textures: {
|
||||
up: {
|
||||
u
|
||||
v
|
||||
su
|
||||
sv
|
||||
elements: [{
|
||||
faces: {
|
||||
[face: string]: {
|
||||
texture: {
|
||||
u
|
||||
v
|
||||
su
|
||||
sv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}>>
|
||||
}>
|
||||
}>
|
||||
|
||||
let blockStates: BlockStates
|
||||
let lastInventory
|
||||
let mcData
|
||||
let version
|
||||
let lastWindow
|
||||
let version: string
|
||||
let PrismarineBlock: typeof PrismarineBlockLoader.Block
|
||||
|
||||
subscribeKey(miscUiState, 'gameLoaded', async () => {
|
||||
if (!miscUiState.gameLoaded) {
|
||||
// loadedBlocksAtlas = null
|
||||
return
|
||||
}
|
||||
|
||||
// on game load
|
||||
export const onGameLoad = (onLoad) => {
|
||||
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)
|
||||
})
|
||||
getImage({ path: 'invsprite' })
|
||||
getImage({ path: 'items' }, onLoad)
|
||||
getImage({ path: 'items-legacy' })
|
||||
PrismarineBlock = PrismarineBlockLoader(version)
|
||||
|
||||
const findBlockStateTexturesAtlas = (name) => {
|
||||
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
|
||||
const firstVar = Object.values(vars)[0]
|
||||
if (!firstVar || !Array.isArray(firstVar)) return
|
||||
return firstVar[0]?.model.textures
|
||||
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 blocksImg = loadedImages.get('blocks')
|
||||
if (!blocksImg?.width) return
|
||||
|
||||
const data = findBlockStateTexturesAtlas(name)
|
||||
const data = findTextureInBlockStates(name)
|
||||
if (!data) return
|
||||
|
||||
const getSpriteBlockSide = (side) => {
|
||||
const d = data[side]
|
||||
const d = data[side]?.texture
|
||||
if (!d) return
|
||||
const spriteSide = [d.u * blocksImg.width, d.v * blocksImg.height, d.su * blocksImg.width, d.sv * blocksImg.height]
|
||||
const spriteSide = svSuToCoordinates('blocks', d.u, d.v, d.su, d.sv)
|
||||
const blockSideData = {
|
||||
slice: spriteSide,
|
||||
path: 'blocks'
|
||||
|
|
@ -77,8 +115,8 @@ const getBlockData = (name) => {
|
|||
}
|
||||
}
|
||||
|
||||
const getItemSlice = (name) => {
|
||||
const invspriteImg = loadedImages.get('invsprite')
|
||||
const getInvspriteSlice = (name) => {
|
||||
const invspriteImg = loadedImagesCache.get('invsprite')
|
||||
if (!invspriteImg?.width) return
|
||||
|
||||
const { x, y } = invspriteJson[name] ?? /* unknown item */ { x: 0, y: 0 }
|
||||
|
|
@ -86,68 +124,203 @@ const getItemSlice = (name) => {
|
|||
return sprite
|
||||
}
|
||||
|
||||
const getImageSrc = (path) => {
|
||||
const getImageSrc = (path): string | HTMLImageElement => {
|
||||
switch (path) {
|
||||
case 'gui/container/inventory': return InventoryGui
|
||||
case 'blocks': return globalThis.texturePackDataUrl || `textures/${version}.png`
|
||||
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, texture, blockData }) => {
|
||||
const getImage = ({ path = undefined, texture = undefined, blockData = undefined }, onLoad = () => {}) => {
|
||||
const loadPath = blockData ? 'blocks' : path ?? texture
|
||||
if (!loadedImages.has(loadPath)) {
|
||||
const image = new Image()
|
||||
// image.onload(() => {})
|
||||
image.src = getImageSrc(loadPath)
|
||||
loadedImages.set(loadPath, image)
|
||||
if (!loadedImagesCache.has(loadPath)) {
|
||||
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 loadedImages.get(loadPath)
|
||||
return loadedImagesCache.get(loadPath)
|
||||
}
|
||||
|
||||
const upInventory = () => {
|
||||
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 customSlots = bot.inventory.slots.map(slot => {
|
||||
const updateSlots = (inventory ? bot.inventory : bot.currentWindow).slots.map(slot => {
|
||||
// todo stateid
|
||||
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)
|
||||
try {
|
||||
const slotCustomProps = renderSlot(slot)
|
||||
Object.assign(slot, slotCustomProps)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
return slot
|
||||
})
|
||||
lastInventory.pwindow.setSlots(customSlots)
|
||||
const customSlots = updateSlots
|
||||
lastWindow.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()
|
||||
export const onModalClose = (callback: () => any) => {
|
||||
const { length } = activeModalStack
|
||||
const unsubscribe = subscribe(activeModalStack, () => {
|
||||
if (activeModalStack.length < length) {
|
||||
callback()
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
lastInventory = inv
|
||||
upInventory()
|
||||
} else if (lastInventory) {
|
||||
lastInventory.destroy()
|
||||
lastInventory = null
|
||||
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(() => {
|
||||
if (type !== undefined) 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const { subscribeKey } = require('valtio/utils')
|
|||
const invsprite = require('../../invsprite.json')
|
||||
const { isGameActive, miscUiState, showModal } = require('../../globalState')
|
||||
|
||||
const { openPlayerInventory, renderSlotExternal } = require('../../inventory')
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +117,7 @@ class Hotbar extends LitElement {
|
|||
updated (changedProperties) {
|
||||
if (changedProperties.has('bot')) {
|
||||
// inventory listener
|
||||
this.bot.once('spawn', () => {
|
||||
bot.once('spawn', () => {
|
||||
this.init()
|
||||
})
|
||||
}
|
||||
|
|
@ -132,7 +130,7 @@ class Hotbar extends LitElement {
|
|||
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,
|
||||
|
|
@ -145,38 +143,41 @@ class Hotbar extends LitElement {
|
|||
this.reloadHotbarSelected(numPressed - 1)
|
||||
})
|
||||
|
||||
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')`
|
||||
}
|
||||
if (data?.sprite) {
|
||||
const [x, y] = data.sprite
|
||||
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 +203,7 @@ class Hotbar extends LitElement {
|
|||
</div>
|
||||
`)}
|
||||
${miscUiState.currentTouch ? html`<div class="hotbar-item hotbar-more" @pointerdown=${() => {
|
||||
showModal({ reactType: 'inventory' })
|
||||
openPlayerInventory()
|
||||
}}>` : undefined}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@ class Hud extends LitElement {
|
|||
const xpLabel = this.shadowRoot.querySelector('#xp-label')
|
||||
|
||||
this.bot = bot
|
||||
hotbar.bot = bot
|
||||
debugMenu.bot = bot
|
||||
|
||||
hotbar.init()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const defaultOptions = {
|
|||
highPerformanceGpu: false,
|
||||
/** @unstable */
|
||||
disableAssets: false,
|
||||
unimplementedContainers: false,
|
||||
|
||||
showChunkBorders: false,
|
||||
frameLimit: false as number | false,
|
||||
|
|
|
|||
|
|
@ -271,8 +271,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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue