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

This commit is contained in:
Vitaly Turovsky 2025-03-15 05:36:54 +03:00
commit d74d860726
34 changed files with 609 additions and 130 deletions

43
.github/workflows/build-zip.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Make Self Host Zip
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 project
run: pnpm build
- name: Bundle server.js
run: |
pnpm esbuild server.js --bundle --platform=node --outfile=bundled-server.js --define:process.env.NODE_ENV="'production'"
- name: Create distribution package
run: |
mkdir -p package
cp -r dist package/
cp bundled-server.js package/server.js
cd package
zip -r ../self-host.zip .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: self-host
path: self-host.zip

View file

@ -32,6 +32,8 @@ jobs:
echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
- run: pnpm build-storybook
- name: Copy playground files
run: |

View file

@ -61,6 +61,8 @@ jobs:
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
- run: pnpm build-storybook
- name: Copy playground files
run: |

View file

@ -30,6 +30,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
env:
CONFIG_JSON_SOURCE: BUNDLED
- run: pnpm build-storybook
- name: Copy playground files
run: |
@ -43,19 +45,36 @@ jobs:
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
id: deploy
- run: |
pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag
id: release
# has output
# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .vercel/output/static
force_orphan: true
- name: Build self-host version
run: pnpm build
- name: Bundle server.js
run: |
pnpm esbuild server.js --bundle --platform=node --outfile=bundled-server.js --define:process.env.NODE_ENV="'production'"
- name: Create zip package
run: |
mkdir -p package
cp -r dist package/
cp bundled-server.js package/server.js
cd package
zip -r ../self-host.zip .
- run: |
pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag
id: release
# has output
- name: Set publishing config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:

View file

@ -9,6 +9,7 @@ RUN npm i -g pnpm@9.0.4
# Build arguments
ARG DOWNLOAD_SOUNDS=false
ARG DISABLE_SERVICE_WORKER=false
ARG CONFIG_JSON_SOURCE=REMOTE
# TODO need flat --no-root-optional
RUN node ./scripts/dockerPrepare.mjs
RUN pnpm i
@ -22,8 +23,8 @@ RUN if [ "$DOWNLOAD_SOUNDS" = "true" ] ; then node scripts/downloadSoundsMap.mjs
# ENTRYPOINT ["pnpm", "run", "run-all"]
# only for prod
RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client \
DISABLE_SERVICE_WORKER=$DISABLE_SERVICE_WORKER \
RUN DISABLE_SERVICE_WORKER=$DISABLE_SERVICE_WORKER \
CONFIG_JSON_SOURCE=$CONFIG_JSON_SOURCE \
pnpm run build
# ---- Run Stage ----

View file

@ -6,6 +6,10 @@
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "ws://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "ws://play.mcraft.fun"
},

View file

@ -35,6 +35,9 @@
"web",
"client"
],
"release": {
"attachReleaseFiles": "self-host.zip"
},
"publish": {
"preset": {
"publishOnlyIfChanged": true,
@ -145,8 +148,8 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.42",
"mineflayer-mouse": "^0.0.5",
"mc-assets": "^0.2.37",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",

22
pnpm-lock.yaml generated
View file

@ -350,8 +350,8 @@ importers:
specifier: ^1.0.0
version: 1.0.0
mc-assets:
specifier: ^0.2.37
version: 0.2.37
specifier: ^0.2.42
version: 0.2.42
minecraft-inventory-gui:
specifier: github:zardoy/minecraft-inventory-gui#next
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0)
@ -3589,9 +3589,6 @@ packages:
resolution: {integrity: sha512-FCAJojipPn0bXjuEpjOOOMN8FZDkxfWWp4JGN9mifU2IhxvKyXZYqpzPHdnTSUpmPDy+tsslB6Z1g+Vg6nVbYA==}
engines: {node: '>=8'}
apl-image-packer@1.1.0:
resolution: {integrity: sha512-Pb1Q76cp8xpY8X4OqVrnk5v1/tB5kOtCzwgOnx8IxMNeekFh/eNUiUKeX5fvGNViZiLmuKAAQc5ICuBDspZ4cA==}
app-root-dir@1.0.2:
resolution: {integrity: sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==}
@ -6571,8 +6568,11 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mc-assets@0.2.37:
resolution: {integrity: sha512-49tk3shwxsDoV0PrrbORZEKg613vUQPULgusWjXNl8JEma5y41LEo57D6q4aHliXsV3Gb9ThrkFf6hIb0WlY1Q==}
maxrects-packer@2.7.3:
resolution: {integrity: sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==}
mc-assets@0.2.42:
resolution: {integrity: sha512-j2D1RNYtB5Z9gFu9MVjyDBbiALI0mWZ3xW/A3PPefVAHm3HJ2T1vH+1XBov1spBGPl7u+Zo7mRXza3X0egbeOg==}
engines: {node: '>=18.0.0'}
mcraft-fun-mineflayer@0.1.8:
@ -13609,8 +13609,6 @@ snapshots:
apache-md5@1.1.8: {}
apl-image-packer@1.1.0: {}
app-root-dir@1.0.2: {}
aproba@2.0.0:
@ -17367,9 +17365,11 @@ snapshots:
math-intrinsics@1.1.0: {}
mc-assets@0.2.37:
maxrects-packer@2.7.3: {}
mc-assets@0.2.42:
dependencies:
apl-image-packer: 1.1.0
maxrects-packer: 2.7.3
zod: 3.24.1
mcraft-fun-mineflayer@0.1.8(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)):

View file

@ -0,0 +1,275 @@
// Import placeholders - replace with actual imports for your environment
import { ItemRenderer, Identifier, ItemStack, NbtString, Structure, StructureRenderer, ItemRendererResources, BlockDefinition, BlockModel, TextureAtlas, Resources, ItemModel } from 'deepslate'
import { mat4, vec3 } from 'gl-matrix'
import { AssetsParser } from 'mc-assets/dist/assetsParser'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { BlockModel as BlockModelMcAssets, AtlasParser } from 'mc-assets'
import { getLoadedBlockstatesStore, getLoadedModelsStore } from 'mc-assets/dist/stores'
import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
import { proxy, ref } from 'valtio'
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
export const activeGuiAtlas = proxy({
atlas: null as null | { json, image },
})
export const getNonFullBlocksModels = () => {
const version = viewer.world.texturesVersion ?? 'latest'
const itemsDefinitions = viewer.world.itemsDefinitionsStore.data.latest
const blockModelsResolved = {} as Record<string, any>
const itemsModelsResolved = {} as Record<string, any>
const fullBlocksWithNonStandardDisplay = [] as string[]
const handledItemsWithDefinitions = new Set()
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(viewer.world.blockstatesModels), getLoadedModelsStore(viewer.world.blockstatesModels))
const standardGuiDisplay = {
'rotation': [
30,
225,
0
],
'translation': [
0,
0,
0
],
'scale': [
0.625,
0.625,
0.625
]
}
const arrEqual = (a: number[], b: number[]) => a.length === b.length && a.every((x, i) => x === b[i])
const addModelIfNotFullblock = (name: string, model: BlockModelMcAssets) => {
if (blockModelsResolved[name]) return
if (!model?.elements?.length) return
const isFullBlock = model.elements.length === 1 && arrEqual(model.elements[0].from, [0, 0, 0]) && arrEqual(model.elements[0].to, [16, 16, 16])
if (isFullBlock) return
model['display'] ??= {}
model['display']['gui'] ??= standardGuiDisplay
blockModelsResolved[name] = model
}
for (const [name, definition] of Object.entries(itemsDefinitions)) {
const item = getItemDefinition(viewer.world.itemsDefinitionsStore, {
version,
name,
properties: {
'minecraft:display_context': 'gui',
},
})
if (item) {
const { resolvedModel } = assetsParser.getResolvedModelsByModel((item.special ? name : item.model).replace('minecraft:', '')) ?? {}
if (resolvedModel) {
handledItemsWithDefinitions.add(name)
}
if (resolvedModel?.elements) {
let hasStandardDisplay = true
if (resolvedModel['display']?.gui) {
hasStandardDisplay =
arrEqual(resolvedModel['display'].gui.rotation, standardGuiDisplay.rotation)
&& arrEqual(resolvedModel['display'].gui.translation, standardGuiDisplay.translation)
&& arrEqual(resolvedModel['display'].gui.scale, standardGuiDisplay.scale)
}
addModelIfNotFullblock(name, resolvedModel)
if (!blockModelsResolved[name] && !hasStandardDisplay) {
fullBlocksWithNonStandardDisplay.push(name)
}
const notSideLight = resolvedModel['gui_light'] && resolvedModel['gui_light'] !== 'side'
if (!hasStandardDisplay || notSideLight) {
blockModelsResolved[name] = resolvedModel
}
}
if (!blockModelsResolved[name] && item.tints && resolvedModel) {
resolvedModel['tints'] = item.tints
if (resolvedModel.elements) {
blockModelsResolved[name] = resolvedModel
} else {
itemsModelsResolved[name] = resolvedModel
}
}
}
}
for (const [name, blockstate] of Object.entries(viewer.world.blockstatesModels.blockstates.latest)) {
if (handledItemsWithDefinitions.has(name)) {
continue
}
const resolvedModel = assetsParser.getResolvedModelFirst({ name: name.replace('minecraft:', ''), properties: {} }, true)
if (resolvedModel) {
addModelIfNotFullblock(name, resolvedModel[0])
}
}
return {
blockModelsResolved,
itemsModelsResolved
}
}
// customEvents.on('gameLoaded', () => {
// const res = getNonFullBlocksModels()
// })
const RENDER_SIZE = 64
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
const img = await getLoadedImage(isItems ? viewer.world.itemsAtlasParser!.latestImage : viewer.world.blocksAtlasParser!.latestImage)
const canvasTemp = document.createElement('canvas')
canvasTemp.width = img.width
canvasTemp.height = img.height
canvasTemp.style.imageRendering = 'pixelated'
const ctx = canvasTemp.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(img, 0, 0)
const atlasParser = isItems ? viewer.world.itemsAtlasParser! : viewer.world.blocksAtlasParser!
const textureAtlas = new TextureAtlas(
ctx.getImageData(0, 0, img.width, img.height),
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
return [key, [
value.u,
value.v,
(value.u + (value.su ?? atlasParser.atlas.latest.suSv)),
(value.v + (value.sv ?? atlasParser.atlas.latest.suSv)),
]] as [string, [number, number, number, number]]
}))
)
const PREVIEW_ID = Identifier.parse('preview:preview')
const PREVIEW_DEFINITION = new BlockDefinition({ '': { model: PREVIEW_ID.toString() } }, undefined)
let modelData: any
let currentModelName: string | undefined
const resources: ItemRendererResources = {
getBlockModel (id) {
if (id.equals(PREVIEW_ID)) {
return BlockModel.fromJson(modelData ?? {})
}
return null
},
getTextureUV (texture) {
return textureAtlas.getTextureUV(texture.toString().slice(1).split('/').slice(1).join('/') as any)
},
getTextureAtlas () {
return textureAtlas.getTextureAtlas()
},
getItemComponents (id) {
return new Map()
},
getItemModel (id) {
// const isSpecial = currentModelName === 'shield' || currentModelName === 'conduit' || currentModelName === 'trident'
const isSpecial = false
if (id.equals(PREVIEW_ID)) {
return ItemModel.fromJson({
type: isSpecial ? 'minecraft:special' : 'minecraft:model',
model: isSpecial ? {
type: currentModelName,
} : PREVIEW_ID.toString(),
base: PREVIEW_ID.toString(),
tints: modelData?.tints,
})
}
return null
},
}
const canvas = document.createElement('canvas')
canvas.width = RENDER_SIZE
canvas.height = RENDER_SIZE
const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true })
if (!gl) {
throw new Error('Cannot get WebGL2 context')
}
function resetGLContext (gl) {
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT)
}
// const includeOnly = ['powered_repeater', 'wooden_door']
const includeOnly = [] as string[]
const images: Record<string, HTMLImageElement> = {}
const item = new ItemStack(PREVIEW_ID, 1, new Map(Object.entries({
'minecraft:item_model': new NbtString(PREVIEW_ID.toString()),
})))
const renderer = new ItemRenderer(gl, item, resources, { display_context: 'gui' })
const missingTextures = new Set()
for (const [modelName, model] of Object.entries(models)) {
if (includeOnly.length && !includeOnly.includes(modelName)) continue
const patchMissingTextures = () => {
for (const element of model.elements ?? []) {
for (const [faceName, face] of Object.entries(element.faces)) {
if (face.texture.startsWith('#')) {
missingTextures.add(`${modelName} ${faceName}: ${face.texture}`)
face.texture = 'block/unknown'
}
}
}
}
patchMissingTextures()
// TODO eggs
modelData = model
currentModelName = modelName
resetGLContext(gl)
if (!modelData) continue
renderer.setItem(item, { display_context: 'gui' })
renderer.drawItem()
const url = canvas.toDataURL()
// eslint-disable-next-line no-await-in-loop
const img = await getLoadedImage(url)
images[modelName] = img
}
if (missingTextures.size) {
console.warn(`[guiRenderer] Missing textures in ${[...missingTextures].join(', ')}`)
}
return images
}
const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
const atlas = makeTextureAtlas({
input: Object.keys(images),
tileSize: RENDER_SIZE,
getLoadedImage (name) {
return {
image: images[name],
}
},
})
// const atlasParser = new AtlasParser({ latest: atlas.json }, atlas.canvas.toDataURL())
// const a = document.createElement('a')
// a.href = await atlasParser.createDebugImage(true)
// a.download = 'blocks_atlas.png'
// a.click()
activeGuiAtlas.atlas = {
json: atlas.json,
image: ref(await getLoadedImage(atlas.canvas.toDataURL())),
}
return atlas
}
export const generateGuiAtlas = async () => {
const { blockModelsResolved, itemsModelsResolved } = getNonFullBlocksModels()
// Generate blocks atlas
console.time('generate blocks gui atlas')
const blockImages = await generateItemsGui(blockModelsResolved, false)
console.timeEnd('generate blocks gui atlas')
console.time('generate items gui atlas')
const itemImages = await generateItemsGui(itemsModelsResolved, true)
console.timeEnd('generate items gui atlas')
await generateAtlas({ ...blockImages, ...itemImages })
// await generateAtlas(blockImages)
}

View file

@ -15,6 +15,7 @@ import { buildCleanupDecorator } from './cleanupDecorator'
import { defaultMesherConfig, HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo } from './mesher/shared'
import { chunkPos } from './simpleUtils'
import { updateStatText } from './ui/newStats'
import { generateGuiAtlas } from './guiRenderer'
const appViewer = undefined
@ -332,6 +333,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
}
async generateGuiTextures () {
await generateGuiAtlas()
}
async updateAssetsData () {
const texture = await new THREE.TextureLoader().loadAsync(this.resourcesManager.blocksAtlasParser!.latestImage)
texture.magFilter = THREE.NearestFilter
@ -365,11 +370,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
config: this.mesherConfig,
})
}
const itemsTexture = await new THREE.TextureLoader().loadAsync(this.resourcesManager.itemsAtlasParser!.latestImage)
const itemsTexture = await new THREE.TextureLoader().loadAsync(this.itemsAtlasParser.latestImage)
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
itemsTexture.flipY = false
viewer.entities.itemsTexture = itemsTexture
this.renderUpdateEmitter.emit('textureDownloaded')
this.renderUpdateEmitter.emit('itemsTextureDownloaded')
console.log('textures loaded')
}
async downloadDebugAtlas (isItems = false) {

View file

@ -25,12 +25,14 @@ const disableServiceWorker = process.env.DISABLE_SERVICE_WORKER === 'true'
let releaseTag
let releaseLink
let releaseChangelog
let githubRepositoryFallback
if (fs.existsSync('./assets/release.json')) {
const releaseJson = JSON.parse(fs.readFileSync('./assets/release.json', 'utf8'))
releaseTag = releaseJson.latestTag
releaseLink = releaseJson.isCommit ? `/commit/${releaseJson.latestTag}` : `/releases/${releaseJson.latestTag}`
releaseChangelog = releaseJson.changelog?.replace(/<!-- bump-type:[\w]+ -->/, '')
githubRepositoryFallback = releaseJson.repository
}
const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8'))
@ -41,6 +43,8 @@ if (dev) {
configJson.defaultProxy = ':8080'
}
const configSource = process.env.CONFIG_JSON_SOURCE || 'REMOTE'
// base options are in ./renderer/rsbuildSharedConfig.ts
const appConfig = defineConfig({
html: {
@ -66,13 +70,13 @@ const appConfig = defineConfig({
'process.env.BUILD_VERSION': JSON.stringify(!dev ? buildingVersion : 'undefined'),
'process.env.MAIN_MENU_LINKS': JSON.stringify(process.env.MAIN_MENU_LINKS),
'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}`}`),
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({}),
'process.env.RELEASE_TAG': JSON.stringify(releaseTag),
'process.env.RELEASE_LINK': JSON.stringify(releaseLink),
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
'process.env.INLINED_APP_CONFIG': JSON.stringify(configJson),
'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null),
},
},
server: {
@ -109,7 +113,9 @@ const appConfig = defineConfig({
fs.copyFileSync('./assets/release.json', './dist/release.json')
}
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson), 'utf8')
if (configSource === 'REMOTE') {
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson), 'utf8')
}
if (fs.existsSync('./generated/sounds.js')) {
fs.copyFileSync('./generated/sounds.js', './dist/sounds.js')
}

View file

@ -4,9 +4,27 @@ import path from 'path'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
// write release tag
// Get repository from git config
const getGitRepository = () => {
try {
const gitConfig = fs.readFileSync('.git/config', 'utf8')
const originUrlMatch = gitConfig.match(/\[remote "origin"\][\s\S]*?url = .*?github\.com[:/](.*?)(\.git)?\n/m)
if (originUrlMatch) {
return originUrlMatch[1]
}
} catch (err) {
console.warn('Failed to read git repository from config:', err)
}
return null
}
// write release tag and repository info
const commitShort = execSync('git rev-parse --short HEAD').toString().trim()
fs.writeFileSync('./assets/release.json', JSON.stringify({ latestTag: `${commitShort} (docker)` }), 'utf8')
const repository = getGitRepository()
fs.writeFileSync('./assets/release.json', JSON.stringify({
latestTag: `${commitShort} (docker)`,
repository
}), 'utf8')
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
delete packageJson.optionalDependencies

View file

@ -15,7 +15,7 @@ try {
// Create our app
const app = express()
const isProd = process.argv.includes('--prod')
const isProd = process.argv.includes('--prod') || process.env.NODE_ENV === 'production'
app.use(compression())
app.use(cors())
app.use(netApi({ allowOrigin: '*' }))

59
src/appConfig.ts Normal file
View file

@ -0,0 +1,59 @@
import { disabledSettings, options, qsOptions } from './optionsStorage'
import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus'
export type AppConfig = {
// defaultHost?: string
// defaultHostSave?: string
defaultProxy?: string
// defaultProxySave?: string
// defaultVersion?: string
peerJsServer?: string
peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, version? }>
mapsProvider?: string
appParams?: Record<string, any> // query string params
defaultSettings?: Record<string, any>
forceSettings?: Record<string, boolean>
// hideSettings?: Record<string, boolean>
allowAutoConnect?: boolean
pauseLinks?: Array<Array<Record<string, any>>>
}
export const loadAppConfig = (appConfig: AppConfig) => {
if (miscUiState.appConfig) {
Object.assign(miscUiState.appConfig, appConfig)
} else {
miscUiState.appConfig = appConfig
}
if (appConfig.forceSettings) {
for (const [key, value] of Object.entries(appConfig.forceSettings)) {
if (value) {
disabledSettings.value.add(key)
// since the setting is forced, we need to set it to that value
if (appConfig.defaultSettings?.[key] && !qsOptions[key]) {
options[key] = appConfig.defaultSettings[key]
}
} else {
disabledSettings.value.delete(key)
}
}
}
}
export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG
if (isBundledConfigUsed) {
loadAppConfig(process.env.INLINED_APP_CONFIG as AppConfig ?? {})
} else {
void window.fetch('config.json').then(async res => res.json()).then(c => c, (error) => {
// console.warn('Failed to load optional app config.json', error)
// return {}
setLoadingScreenStatus('Failed to load app config.json', true)
}).then((config: AppConfig) => {
loadAppConfig(config)
})
}

View file

@ -1,4 +1,4 @@
import type { AppConfig } from './globalState'
import type { AppConfig } from './appConfig'
const qsParams = new URLSearchParams(window.location?.search ?? '')

View file

@ -17,17 +17,30 @@ const convertedSounds = [] as string[]
export async function loadSound (path: string, contents = path) {
if (loadingSounds.includes(path)) return true
loadingSounds.push(path)
const res = await window.fetch(contents)
if (!res.ok) {
const error = `Failed to load sound ${path}`
if (isCypress()) throw new Error(error)
else console.warn(error)
return
}
const data = await res.arrayBuffer()
sounds[path] = data
loadingSounds.splice(loadingSounds.indexOf(path), 1)
try {
audioContext ??= new window.AudioContext()
const res = await window.fetch(contents)
if (!res.ok) {
const error = `Failed to load sound ${path}`
if (isCypress()) throw new Error(error)
else console.warn(error)
return
}
const arrayBuffer = await res.arrayBuffer()
// Decode the audio data immediately
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
sounds[path] = audioBuffer
convertedSounds.push(path) // Mark as converted immediately
loadingSounds.splice(loadingSounds.indexOf(path), 1)
} catch (err) {
console.warn(`Failed to load sound ${path}:`, err)
loadingSounds.splice(loadingSounds.indexOf(path), 1)
if (isCypress()) throw err
}
}
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => {
@ -53,13 +66,6 @@ export async function playSound (url, soundVolume = 1) {
return
}
for (const [soundName, sound] of Object.entries(sounds)) {
if (convertedSounds.includes(soundName)) continue
// eslint-disable-next-line no-await-in-loop
sounds[soundName] = await audioContext.decodeAudioData(sound)
convertedSounds.push(soundName)
}
const soundBuffer = sounds[url]
if (!soundBuffer) {
console.warn(`Sound ${url} not loaded yet`)

View file

@ -41,13 +41,13 @@ browserfs.configure({
throw e2
}
showNotification('Failed to access device storage', `Check you have free space. ${e.message}`, true)
miscUiState.appLoaded = true
miscUiState.fsReady = true
miscUiState.singleplayerAvailable = false
})
return
}
await updateTexturePackInstalledState()
miscUiState.appLoaded = true
miscUiState.fsReady = true
miscUiState.singleplayerAvailable = true
})

View file

@ -13,12 +13,14 @@ export interface ProgressReporter {
setMessage (message: string): void
end (): void
end(): void
error(message: string): void
}
interface ReporterDisplayImplementation {
setMessage (message: string): void
end (): void
error(message: string): void
}
interface StageInfo {
@ -124,6 +126,10 @@ const createProgressReporter = (implementation: ReporterDisplayImplementation):
get currentMessage () {
return currentMessage
},
error (message: string): void {
implementation.error(message)
}
}
@ -145,6 +151,11 @@ export const createFullScreenProgressReporter = (): ProgressReporter => {
} else {
setLoadingScreenStatus(fullScreenReporters.at(-1)!.currentMessage)
}
},
error (message: string): void {
if (appStatusState.isError) return
setLoadingScreenStatus(message, true)
}
})
fullScreenReporters.push(reporter)
@ -162,6 +173,10 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres
} else {
hideNotification()
}
},
error (message: string): void {
showNotification(message, '', true, '', undefined, true)
}
})
}
@ -173,6 +188,10 @@ export const createConsoleLogProgressReporter = (group?: string): ProgressReport
},
end () {
console.log(group ? `[${group}] done` : 'done')
},
error (message: string): void {
console.error(message)
}
})
}
@ -191,6 +210,10 @@ export const createWrappedProgressReporter = (reporter: ProgressReporter, messag
if (message) {
reporter.endStage(stage)
}
},
error (message: string): void {
reporter.error(message)
}
})
}
@ -200,6 +223,8 @@ export const createNullProgressReporter = (): ProgressReporter => {
setMessage (message: string) {
},
end () {
},
error (message: string) {
}
})
}

View file

@ -85,15 +85,15 @@ const registeredJeiChannel = () => {
[
{
name: 'id',
type: 'pstring',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'categoryTitle',
type: 'pstring',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'items',
type: 'pstring',
type: ['pstring', { countType: 'i16' }]
},
]
]

View file

@ -5,6 +5,7 @@ import type { WorldWarp } from 'flying-squid/dist/lib/modules/warps'
import type { OptionsGroupType } from './optionsGuiScheme'
import { appQueryParams } from './appParams'
import { options, disabledSettings } from './optionsStorage'
import { AppConfig } from './appConfig'
// todo: refactor structure with support of hideNext=false
@ -110,26 +111,6 @@ export const showContextmenu = (items: ContextMenuItem[], { clientX, clientY })
// ---
export type AppConfig = {
// defaultHost?: string
// defaultHostSave?: string
defaultProxy?: string
// defaultProxySave?: string
// defaultVersion?: string
peerJsServer?: string
peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, version? }>
mapsProvider?: string
appParams?: Record<string, any> // query string params
defaultSettings?: Record<string, any>
forceSettings?: Record<string, boolean>
// hideSettings?: Record<string, boolean>
allowAutoConnect?: boolean
pauseLinks?: Array<Array<Record<string, any>>>
}
export const miscUiState = proxy({
currentDisplayQr: null as string | null,
currentTouch: null as boolean | null,
@ -144,7 +125,7 @@ export const miscUiState = proxy({
loadedServerIndex: '',
/** currently trying to load or loaded mc version, after all data is loaded */
loadedDataVersion: null as string | null,
appLoaded: false,
fsReady: false,
singleplayerAvailable: false,
usingGamepadInput: false,
appConfig: null as AppConfig | null,
@ -152,24 +133,6 @@ export const miscUiState = proxy({
displayFullmap: false
})
export const loadAppConfig = (appConfig: AppConfig) => {
if (miscUiState.appConfig) {
Object.assign(miscUiState.appConfig, appConfig)
} else {
miscUiState.appConfig = appConfig
}
if (appConfig.forceSettings) {
for (const [key, value] of Object.entries(appConfig.forceSettings)) {
if (value) {
disabledSettings.value.delete(key)
} else {
disabledSettings.value.add(key)
}
}
}
}
export const isGameActive = (foregroundCheck: boolean) => {
if (foregroundCheck && activeModalStack.length) return false
return miscUiState.gameLoaded

View file

@ -12,6 +12,7 @@ import './mineflayer/cameraShake'
import './shims/patchShims'
import './mineflayer/java-tester/index'
import './external'
import './appConfig'
import { getServerInfo } from './mineflayer/mc-protocol'
import { onGameLoad, renderSlot } from './inventoryWindows'
import { GeneralInputItem } from './mineflayer/items'
@ -46,7 +47,6 @@ import initializePacketsReplay from './packetsReplay/packetsReplayLegacy'
import { initVR } from './vr'
import {
AppConfig,
activeModalStack,
activeModalStacks,
hideModal,
@ -55,7 +55,6 @@ import {
miscUiState,
showModal,
gameAdditionalState,
loadAppConfig
} from './globalState'
import { parseServerAddress } from './parseServerAddress'
@ -836,8 +835,9 @@ export async function connect (connectOptions: ConnectOptions) {
const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined
listenGlobalEvents()
watchValue(miscUiState, async s => {
if (s.appLoaded) { // fs ready
const unsubscribe = watchValue(miscUiState, async s => {
if (s.fsReady && s.appConfig) {
unsubscribe()
if (reconnectOptions) {
sessionStorage.removeItem('reconnectOptions')
if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) {
@ -898,15 +898,6 @@ document.body.addEventListener('touchstart', (e) => {
}, { passive: false })
// #endregion
loadAppConfig(process.env.INLINED_APP_CONFIG as AppConfig ?? {})
// load maybe updated config on the server with updated params (just in case)
void window.fetch('config.json').then(async res => res.json()).then(c => c, (error) => {
console.warn('Failed to load optional app config.json', error)
return {}
}).then((config: AppConfig | {}) => {
loadAppConfig(config)
})
// qs open actions
if (!reconnectOptions) {
downloadAndOpenFile().then((downloadAction) => {

View file

@ -10,6 +10,7 @@ import { versionToNumber } from 'renderer/viewer/common/utils'
import { getRenamedData } from 'flying-squid/dist/blockRenames'
import PrismarineChatLoader from 'prismarine-chat'
import { BlockModel } from 'mc-assets'
import { activeGuiAtlas } from 'renderer/viewer/lib/guiRenderer'
import Generic95 from '../assets/generic_95.png'
import { appReplacableResources } from './generated/resources'
import { activeModalStack, hideCurrentModal, hideModal, miscUiState, showModal } from './globalState'
@ -155,7 +156,10 @@ const getImageSrc = (path): string | HTMLImageElement => {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
}
const getImage = ({ path = undefined as string | undefined, texture = undefined as string | undefined, blockData = undefined as any }, onLoad = () => { }) => {
const getImage = ({ path = undefined as string | undefined, texture = undefined as string | undefined, blockData = undefined as any, image = undefined as HTMLImageElement | undefined }, onLoad = () => { }) => {
if (image) {
return image
}
if (!path && !texture) throw new Error('Either pass path or texture')
const loadPath = (blockData ? 'blocks' : path ?? texture)!
if (loadedImagesCache.has(loadPath)) {
@ -184,7 +188,8 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
blockData?: Record<string, { slice, path }> & { resolvedModel: BlockModel },
scale?: number,
slice?: number[],
modelName?: string
modelName?: string,
image?: HTMLImageElement
} | undefined => {
let itemModelName = model.modelName
const originalItemName = itemModelName
@ -196,6 +201,23 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
let itemTexture
if (!fullBlockModelSupport) {
const atlas = activeGuiAtlas.atlas?.json
// todo atlas holds all rendered blocks, not all possibly rendered item/block models, need to request this on demand instead (this is how vanilla works)
const item = atlas?.textures[itemModelName.replace('minecraft:', '').replace('block/', '').replace('blocks/', '').replace('item/', '').replace('items/', '').replace('_inventory', '').replace('_bottom', '')]
if (item) {
const x = item.u * atlas.width
const y = item.v * atlas.height
return {
texture: 'gui',
image: activeGuiAtlas.atlas!.image,
slice: [x, y, atlas.tileSize, atlas.tileSize],
scale: 0.25,
}
}
}
try {
assertDefined(viewer.world.itemsRenderer)
itemTexture =
@ -205,6 +227,8 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
inGameError(`Failed to render item ${itemModelName} (original: ${originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`)
itemTexture = viewer.world.itemsRenderer!.getItemTexture('block/errored')!
}
if ('type' in itemTexture) {
// is item
return {
@ -230,13 +254,13 @@ const getItemName = (slot: Item | RenderItem | null) => {
return text.join('')
}
const mapSlots = (slots: Array<RenderItem | Item | null>) => {
const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
return slots.map((slot, i) => {
// todo stateid
if (!slot) return
try {
const debugIsQuickbar = i === bot.inventory.hotbarStart + bot.quickBarSlot
const debugIsQuickbar = !isJei && i === bot.inventory.hotbarStart + bot.quickBarSlot
const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', })
const slotCustomProps = renderSlot({ modelName }, debugIsQuickbar)
const itemCustomName = getItemName(slot)
@ -305,7 +329,7 @@ const upJei = (search: string) => {
return new PrismarineItem(x.id, 1)
}).filter(a => a !== null)
lastWindow.pwindow.win.jeiSlotsPage = 0
lastWindow.pwindow.win.jeiSlots = mapSlots(matchedSlots)
lastWindow.pwindow.win.jeiSlots = mapSlots(matchedSlots, true)
}
export const openItemsCanvas = (type, _bot = bot as typeof bot | null) => {

View file

@ -13,7 +13,7 @@ export const pingServerVersion = async (ip: string, port?: number, mergeOptions:
...mergeOptions,
}
let latency = 0
let fullInfo = null
let fullInfo: any = null
fakeClient.autoVersionHooks = [(res) => {
latency = res.latency
fullInfo = res

View file

@ -5,7 +5,7 @@ import { proxy, subscribe } from 'valtio/vanilla'
import { subscribeKey } from 'valtio/utils'
import { omitObj } from '@zardoy/utils'
import { appQueryParamsArray } from './appParams'
import type { AppConfig } from './globalState'
import type { AppConfig } from './appConfig'
const isDev = process.env.NODE_ENV === 'development'
const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {}
@ -188,7 +188,7 @@ subscribe(options, () => {
localStorage.options = JSON.stringify(saveOptions)
})
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T, isChanged: boolean) => void) => void
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T, isChanged: boolean) => void) => () => void
export const watchValue: WatchValue = (proxy, callback) => {
const watchedProps = new Set<string>()
@ -198,10 +198,19 @@ export const watchValue: WatchValue = (proxy, callback) => {
return Reflect.get(target, p, receiver)
},
}), false)
const unsubscribes = [] as Array<() => void>
for (const prop of watchedProps) {
subscribeKey(proxy, prop, () => {
callback(proxy, true)
})
unsubscribes.push(
subscribeKey(proxy, prop, () => {
callback(proxy, true)
})
)
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe()
}
}
}

View file

@ -4,6 +4,7 @@ import { activeModalStack, miscUiState } from '../globalState'
import Button from './Button'
import { useUsingTouch } from './utilsApp'
import { pixelartIcons } from './PixelartIcon'
import { showNotification } from './NotificationProvider'
const hideOnModals = new Set(['chat'])
@ -33,8 +34,12 @@ export default () => {
left: inMainMenu ? 35 : 5,
width: 22,
}}
onClick={() => {
void document.documentElement.requestFullscreen()
onClick={async () => {
try {
await document.documentElement.requestFullscreen()
} catch (err) {
showNotification(`${err.message ?? err}`, undefined, true)
}
}}
/>
}

View file

@ -75,12 +75,12 @@ export const mainMenuState = proxy({
let disableAnimation = false
export default () => {
const haveModals = useSnapshot(activeModalStack).length
const { gameLoaded, appLoaded, appConfig, singleplayerAvailable } = useSnapshot(miscUiState)
const { gameLoaded, fsReady, appConfig, singleplayerAvailable } = useSnapshot(miscUiState)
const noDisplay = haveModals || gameLoaded || !appLoaded
const noDisplay = haveModals || gameLoaded || !fsReady
useEffect(() => {
if (noDisplay && appLoaded) disableAnimation = true
if (noDisplay && fsReady) disableAnimation = true
}, [noDisplay])
const [versionStatus, setVersionStatus] = useState('')

View file

@ -60,10 +60,13 @@ export default () => {
// }, [])
const scale = useAppScale()
return <div style={{
transform: `scale(${scale})`,
transformOrigin: 'top right',
}}>
return <div
className='notification-container'
style={{
// transform: `scale(${scale})`,
// transformOrigin: 'top right',
}}
>
<Notification
action={action}
type={type}

View file

@ -17,6 +17,7 @@ import { useCopyKeybinding } from './simpleHooks'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList, StoreServerItem } from './serversStorage'
type AdditionalDisplayData = {
textNameRightGrayed: string
formattedText: string
textNameRight: string
icon?: string
@ -143,9 +144,11 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
let data
if (isWebSocket) {
const pingResult = await getServerInfo(server.ip, undefined, undefined, true)
console.log('pingResult.fullInfo.description', pingResult.fullInfo.description)
data = {
formattedText: `${pingResult.version} server with a direct websocket connection`,
formattedText: pingResult.fullInfo.description,
textNameRight: `ws ${pingResult.latency}ms`,
textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`,
offline: false
}
} else {
@ -364,6 +367,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
detail: (server.versionOverride ?? '') + ' ' + (server.usernameOverride ?? ''),
formattedTextOverride: additional?.formattedText,
worldNameRight: additional?.textNameRight ?? '',
worldNameRightGrayed: additional?.textNameRightGrayed ?? '',
iconSrc: additional?.icon,
offline: additional?.offline
}

View file

@ -24,13 +24,14 @@ export interface WorldProps {
detail?: string
formattedTextOverride?: string
worldNameRight?: string
worldNameRightGrayed?: string
onFocus?: (name: string) => void
onInteraction?(interaction: 'enter' | 'space')
elemRef?: React.Ref<HTMLDivElement>
offline?: boolean
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, worldNameRightGrayed, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const timeRelativeFormatted = useMemo(() => {
if (!lastPlayed) return ''
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
@ -63,6 +64,7 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
<div className={styles.world_title}>
<div>{title}</div>
<div className={styles.world_title_right}>
{worldNameRightGrayed && <span style={{ color: '#878787', fontSize: 8 }}>{worldNameRightGrayed}</span>}
{offline ? (
<span style={{ color: 'red', display: 'flex', alignItems: 'center', gap: 4 }}>
<PixelartIcon iconName="signal-off" width={12} />

View file

@ -36,6 +36,9 @@
.world_title_right {
color: #999;
font-size: 9px;
display: flex;
align-items: end;
gap: 1px;
}
.world_info {
margin-left: 3px;

View file

@ -220,6 +220,7 @@ const App = () => {
<SignInMessageProvider />
<NoModalFoundProvider />
<PacketsReplayProvider />
<NotificationProvider />
</RobustPortal>
<RobustPortal to={document.body}>
<div className='overlay-top-scaled'>
@ -227,7 +228,6 @@ const App = () => {
</div>
<div />
<DebugEdges />
<NotificationProvider />
</RobustPortal>
</ButtonAppProvider>
</div>

View file

@ -393,7 +393,7 @@ const downloadAndUseResourcePack = async (url: string, progressReporter: Progres
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)
progressReporter.error('Failed to download resource pack: ' + err.message)
})
console.timeEnd('downloadServerResourcePack')
if (!response) return
@ -426,6 +426,8 @@ const downloadAndUseResourcePack = async (url: string, progressReporter: Progres
console.error(err)
showNotification('Failed to install resource pack: ' + err.message)
})
} catch (err) {
progressReporter.error('Could not install resource pack: ' + err.message)
} finally {
progressReporter.endStage('download-resource-pack')
resourcePackState.isServerInstalling = false

View file

@ -14,10 +14,11 @@ export const getItemModelName = (item: GeneralInputItem, specificProps: ItemSpec
const itemSelector = playerState.getItemSelector({
...specificProps
})
const model = getItemDefinition(viewer.world.itemsDefinitionsStore, {
const modelFromDef = getItemDefinition(viewer.world.itemsDefinitionsStore, {
name: itemModelName,
version: viewer.world.texturesVersion!,
properties: itemSelector
})?.model ?? itemModelName
})?.model
const model = (modelFromDef === 'minecraft:special' ? undefined : modelFromDef) ?? itemModelName
return model
}

View file

@ -95,7 +95,7 @@ export const watchOptionsAfterViewerInit = () => {
appViewer.inWorldRenderingConfig.enableLighting = !bot.supportFeature('blockStateId') || options.newVersionsLighting
})
customEvents.on('gameLoaded', () => {
customEvents.on('mineflayerBotCreated', () => {
appViewer.inWorldRenderingConfig.enableLighting = !bot.supportFeature('blockStateId') || options.newVersionsLighting
})