This commit is contained in:
Vitaly 2025-06-29 13:37:54 +03:00 committed by GitHub
commit 226e4be4de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 915 additions and 352 deletions

View file

@ -6,9 +6,11 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
### Big Features

View file

@ -76,7 +76,7 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.59",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.62",
"framer-motion": "^12.9.2",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",

36
pnpm-lock.yaml generated
View file

@ -117,8 +117,8 @@ importers:
specifier: ^10.0.12
version: 10.1.6
flying-squid:
specifier: npm:@zardoy/flying-squid@^0.0.59
version: '@zardoy/flying-squid@0.0.59(encoding@0.1.13)'
specifier: npm:@zardoy/flying-squid@^0.0.62
version: '@zardoy/flying-squid@0.0.62(encoding@0.1.13)'
framer-motion:
specifier: ^12.9.2
version: 12.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -136,7 +136,7 @@ importers:
version: 4.17.21
mcraft-fun-mineflayer:
specifier: ^0.1.23
version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13))
version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13))
minecraft-data:
specifier: 3.89.0
version: 3.89.0
@ -338,10 +338,10 @@ importers:
version: 0.2.59
minecraft-inventory-gui:
specifier: github:zardoy/minecraft-inventory-gui#next
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d(@types/react@18.3.18)(react@18.3.1)
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379(@types/react@18.3.18)(react@18.3.1)
mineflayer:
specifier: github:zardoy/mineflayer#gen-the-master
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13)
mineflayer-mouse:
specifier: ^0.1.10
version: 0.1.10(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
@ -3410,8 +3410,8 @@ packages:
engines: {node: '>=8'}
hasBin: true
'@zardoy/flying-squid@0.0.59':
resolution: {integrity: sha512-Ztrmv127csGovqJEWEtT19y1wGEB5tIVfneQ3+p/TirP/bTGYpLlW+Ns4sSAc4KrewUP9PW/6L0AtB69CWhQFQ==}
'@zardoy/flying-squid@0.0.62':
resolution: {integrity: sha512-M6icydO/yrmwevBhmgKcqEPC63AhWfU/Es9N/uadVrmKaxGm2FQMMLcybbutRYm1xZ6qsdxDUOUZnN56PsVwfQ==}
engines: {node: '>=8'}
hasBin: true
@ -6705,8 +6705,8 @@ packages:
minecraft-folder-path@1.2.0:
resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379}
version: 1.0.1
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284:
@ -6736,8 +6736,8 @@ packages:
resolution: {integrity: sha512-3bxph4jfbkBh5HpeouorxzrfSLNV+i+1gugNJ2jf52HW+rt+tW7eiiFPxrJEsOVkPT/3O/dEIW7j93LRlojMkQ==}
engines: {node: '>=22'}
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b}
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d}
version: 4.27.0
engines: {node: '>=22'}
@ -13249,7 +13249,7 @@ snapshots:
- encoding
- supports-color
'@zardoy/flying-squid@0.0.59(encoding@0.1.13)':
'@zardoy/flying-squid@0.0.62(encoding@0.1.13)':
dependencies:
'@tootallnate/once': 2.0.0
chalk: 5.4.1
@ -13346,7 +13346,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
optional: true
@ -16285,7 +16285,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
optional: true
@ -17127,12 +17127,12 @@ snapshots:
maxrects-packer: '@zardoy/maxrects-packer@2.7.4'
zod: 3.24.2
mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)):
mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13)):
dependencies:
'@zardoy/flying-squid': 0.0.49(encoding@0.1.13)
exit-hook: 2.2.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=09def7a73311f10b6aa8ff9f9b76129589578fa154a8b846b06909ca748c4762)(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13)
prismarine-item: 1.16.0
ws: 8.18.1
transitivePeerDependencies:
@ -17443,7 +17443,7 @@ snapshots:
minecraft-folder-path@1.2.0: {}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d(@types/react@18.3.18)(react@18.3.1):
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379(@types/react@18.3.18)(react@18.3.1):
dependencies:
valtio: 1.13.2(@types/react@18.3.18)(react@18.3.1)
transitivePeerDependencies:
@ -17569,7 +17569,7 @@ snapshots:
- encoding
- supports-color
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13):
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13):
dependencies:
'@nxg-org/mineflayer-physics-util': 1.8.10
minecraft-data: 3.89.0

View file

@ -106,7 +106,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}>
customTexturesDataUrl = undefined as string | undefined
workers: any[] = []
viewerPosition?: Vec3
viewerChunkPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
initialChunkLoadWasStartedIn: number | undefined
@ -499,7 +499,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
timeUpdated? (newTime: number): void
updateViewerPosition (pos: Vec3) {
this.viewerPosition = pos
this.viewerChunkPosition = pos
for (const [key, value] of Object.entries(this.loadedChunks)) {
if (!value) continue
this.updatePosDataChunk?.(key)
@ -513,7 +513,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
getDistance (posAbsolute: Vec3) {
const [botX, botZ] = chunkPos(this.viewerPosition!)
const [botX, botZ] = chunkPos(this.viewerChunkPosition!)
const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16))
const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16))
return [dx, dz] as [number, number]

View file

@ -344,7 +344,7 @@ export class Entities {
}
const dt = this.clock.getDelta()
const botPos = this.worldRenderer.viewerPosition
const botPos = this.worldRenderer.viewerChunkPosition
const VISIBLE_DISTANCE = 10 * 10
// Update regular entities

View file

@ -88,7 +88,7 @@ export class WorldRendererThree extends WorldRendererCommon {
this.renderer = renderer
displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...'
this.starField = new StarField(this.scene)
this.starField = new StarField(this)
this.cursorBlock = new CursorBlock(this)
this.holdingBlock = new HoldingBlock(this)
this.holdingBlockLeft = new HoldingBlock(this, true)
@ -318,10 +318,11 @@ export class WorldRendererThree extends WorldRendererCommon {
section.renderOrder = 500 - chunkDistance
}
updateViewerPosition (pos: Vec3): void {
this.viewerPosition = pos
const cameraPos = this.cameraObject.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number]
this.cameraSectionPos = new Vec3(...cameraPos)
override updateViewerPosition (pos: Vec3): void {
this.viewerChunkPosition = pos
}
cameraSectionPositionUpdate () {
// eslint-disable-next-line guard-for-in
for (const key in this.sectionObjects) {
const value = this.sectionObjects[key]
@ -447,11 +448,35 @@ export class WorldRendererThree extends WorldRendererCommon {
return tex
}
getCameraPosition () {
const worldPos = new THREE.Vector3()
this.camera.getWorldPosition(worldPos)
return worldPos
}
getWorldCameraPosition () {
const pos = this.getCameraPosition()
return new Vec3(
Math.floor(pos.x / 16),
Math.floor(pos.y / 16),
Math.floor(pos.z / 16)
)
}
updateCameraSectionPos () {
const newSectionPos = this.getWorldCameraPosition()
if (!this.cameraSectionPos.equals(newSectionPos)) {
this.cameraSectionPos = newSectionPos
this.cameraSectionPositionUpdate()
}
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
const yOffset = this.playerStateReactive.eyeHeight
this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
this.media.tryIntersectMedia()
this.updateCameraSectionPos()
}
getThirdPersonCamera (pos: THREE.Vector3 | null, yaw: number, pitch: number) {
@ -636,6 +661,8 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
}
this.updateCameraSectionPos()
}
debugChunksVisibilityOverride () {
@ -988,7 +1015,9 @@ class StarField {
}
}
constructor (private readonly scene: THREE.Scene) {
constructor (
private readonly worldRenderer: WorldRendererThree
) {
}
addToScene () {
@ -1030,11 +1059,11 @@ class StarField {
// Create points and add them to the scene
this.points = new THREE.Points(geometry, material)
this.scene.add(this.points)
this.worldRenderer.scene.add(this.points)
const clock = new THREE.Clock()
this.points.onBeforeRender = (renderer, scene, camera) => {
this.points?.position.copy?.(camera.position)
this.points?.position.copy?.(this.worldRenderer.getCameraPosition())
material.uniforms.time.value = clock.getElapsedTime() * speed
}
this.points.renderOrder = -1
@ -1044,7 +1073,7 @@ class StarField {
if (this.points) {
this.points.geometry.dispose();
(this.points.material as THREE.Material).dispose()
this.scene.remove(this.points)
this.worldRenderer.scene.remove(this.points)
this.points = undefined
}

View file

@ -140,6 +140,7 @@ const appConfig = defineConfig({
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null),
'process.env.ENABLE_COOKIE_STORAGE': JSON.stringify(process.env.ENABLE_COOKIE_STORAGE || true),
},
},
server: {

219
src/core/importExport.ts Normal file
View file

@ -0,0 +1,219 @@
import { appStorage } from '../react/appStorageProvider'
import { getChangedSettings, options } from '../optionsStorage'
import { customKeymaps } from '../controls'
import { showInputsModal } from '../react/SelectOption'
interface ExportedFile {
_about: string
options?: Record<string, any>
keybindings?: Record<string, any>
servers?: any[]
username?: string
proxy?: string
proxies?: string[]
accountTokens?: any[]
}
export const importData = async () => {
try {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.click()
const file = await new Promise<File>((resolve) => {
input.onchange = () => {
if (!input.files?.[0]) return
resolve(input.files[0])
}
})
const text = await file.text()
const data = JSON.parse(text)
if (!data._about?.includes('Minecraft Web Client')) {
const doContinue = confirm('This file does not appear to be a Minecraft Web Client profile. Continue anyway?')
if (!doContinue) return
}
// Build available data types for selection
const availableData: Record<keyof Omit<ExportedFile, '_about'>, { present: boolean, description: string }> = {
options: { present: !!data.options, description: 'Game settings and preferences' },
keybindings: { present: !!data.keybindings, description: 'Custom key mappings' },
servers: { present: !!data.servers, description: 'Saved server list' },
username: { present: !!data.username, description: 'Username' },
proxy: { present: !!data.proxy, description: 'Selected proxy server' },
proxies: { present: !!data.proxies, description: 'Global proxies list' },
accountTokens: { present: !!data.accountTokens, description: 'Account authentication tokens' },
}
// Filter to only present data types
const presentTypes = Object.fromEntries(Object.entries(availableData)
.filter(([_, info]) => info.present)
.map<any>(([key, info]) => [key, info]))
if (Object.keys(presentTypes).length === 0) {
alert('No compatible data found in the imported file.')
return
}
const importChoices = await showInputsModal('Select Data to Import', {
mergeData: {
type: 'checkbox',
label: 'Merge with existing data (uncheck to remove old data)',
defaultValue: true,
},
...Object.fromEntries(Object.entries(presentTypes).map(([key, info]) => [key, {
type: 'checkbox',
label: info.description,
defaultValue: true,
}]))
}) as { mergeData: boolean } & Record<keyof ExportedFile, boolean>
if (!importChoices) return
const importedTypes: string[] = []
const shouldMerge = importChoices.mergeData
if (importChoices.options && data.options) {
if (shouldMerge) {
Object.assign(options, data.options)
} else {
for (const key of Object.keys(options)) {
if (key in data.options) {
options[key as any] = data.options[key]
}
}
}
importedTypes.push('settings')
}
if (importChoices.keybindings && data.keybindings) {
if (shouldMerge) {
Object.assign(customKeymaps, data.keybindings)
} else {
for (const key of Object.keys(customKeymaps)) delete customKeymaps[key]
Object.assign(customKeymaps, data.keybindings)
}
importedTypes.push('keybindings')
}
if (importChoices.servers && data.servers) {
if (shouldMerge && appStorage.serversList) {
// Merge by IP, update existing entries and add new ones
const existingIps = new Set(appStorage.serversList.map(s => s.ip))
const newServers = data.servers.filter(s => !existingIps.has(s.ip))
appStorage.serversList = [...appStorage.serversList, ...newServers]
} else {
appStorage.serversList = data.servers
}
importedTypes.push('servers')
}
if (importChoices.username && data.username) {
appStorage.username = data.username
importedTypes.push('username')
}
if ((importChoices.proxy && data.proxy) || (importChoices.proxies && data.proxies)) {
if (!appStorage.proxiesData) {
appStorage.proxiesData = { proxies: [], selected: '' }
}
if (importChoices.proxies && data.proxies) {
if (shouldMerge) {
// Merge unique proxies
const uniqueProxies = new Set([...appStorage.proxiesData.proxies, ...data.proxies])
appStorage.proxiesData.proxies = [...uniqueProxies]
} else {
appStorage.proxiesData.proxies = data.proxies
}
importedTypes.push('proxies list')
}
if (importChoices.proxy && data.proxy) {
appStorage.proxiesData.selected = data.proxy
importedTypes.push('selected proxy')
}
}
if (importChoices.accountTokens && data.accountTokens) {
if (shouldMerge && appStorage.authenticatedAccounts) {
// Merge by unique identifier (assuming accounts have some unique ID or username)
const existingAccounts = new Set(appStorage.authenticatedAccounts.map(a => a.username))
const newAccounts = data.accountTokens.filter(a => !existingAccounts.has(a.username))
appStorage.authenticatedAccounts = [...appStorage.authenticatedAccounts, ...newAccounts]
} else {
appStorage.authenticatedAccounts = data.accountTokens
}
importedTypes.push('account tokens')
}
alert(`Profile imported successfully! Imported data: ${importedTypes.join(', ')}.\nYou may need to reload the page for some changes to take effect.`)
} catch (err) {
console.error('Failed to import profile:', err)
alert('Failed to import profile: ' + (err.message || err))
}
}
export const exportData = async () => {
const data = await showInputsModal('Export Profile', {
profileName: {
type: 'text',
},
exportSettings: {
type: 'checkbox',
defaultValue: true,
},
exportKeybindings: {
type: 'checkbox',
defaultValue: true,
},
exportServers: {
type: 'checkbox',
defaultValue: true,
},
saveUsernameAndProxy: {
type: 'checkbox',
defaultValue: true,
},
exportGlobalProxiesList: {
type: 'checkbox',
defaultValue: false,
},
exportAccountTokens: {
type: 'checkbox',
defaultValue: false,
},
})
const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json`
const json: ExportedFile = {
_about: 'Minecraft Web Client (mcraft.fun) Profile',
...data.exportSettings ? {
options: getChangedSettings(),
} : {},
...data.exportKeybindings ? {
keybindings: customKeymaps,
} : {},
...data.exportServers ? {
servers: appStorage.serversList,
} : {},
...data.saveUsernameAndProxy ? {
username: appStorage.username,
proxy: appStorage.proxiesData?.selected,
} : {},
...data.exportGlobalProxiesList ? {
proxies: appStorage.proxiesData?.proxies,
} : {},
...data.exportAccountTokens ? {
accountTokens: appStorage.authenticatedAccounts,
} : {},
}
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}

152
src/defaultOptions.ts Normal file
View file

@ -0,0 +1,152 @@
export const defaultOptions = {
renderDistance: 3,
keepChunksDistance: 1,
multiplayerRenderDistance: 3,
closeConfirmation: true,
autoFullScreen: false,
mouseRawInput: true,
autoExitFullscreen: false,
localUsername: 'wanderer',
mouseSensX: 50,
mouseSensY: -1,
chatWidth: 320,
chatHeight: 180,
chatScale: 100,
chatOpacity: 100,
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
// fov: 70,
fov: 75,
guiScale: 3,
autoRequestCompletions: true,
touchButtonsSize: 40,
touchButtonsOpacity: 80,
touchButtonsPosition: 12,
touchControlsPositions: getDefaultTouchControlsPositions(),
touchControlsSize: getTouchControlsSize(),
touchMovementType: 'modern' as 'modern' | 'classic',
touchInteractionType: 'classic' as 'classic' | 'buttons',
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
backgroundRendering: '20fps' as 'full' | '20fps' | '5fps',
/** @unstable */
disableAssets: false,
/** @unstable */
debugLogNotFrequentPackets: false,
unimplementedContainers: false,
dayCycleAndLighting: true,
loadPlayerSkins: true,
renderEars: true,
lowMemoryMode: false,
starfieldRendering: true,
enabledResourcepack: null as string | null,
useVersionsTextures: 'latest',
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
showHand: true,
viewBobbing: true,
displayRecordButton: true,
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
customChannels: false,
remoteContentNotSameOrigin: false as boolean | string[],
packetsRecordingAutoStart: false,
language: 'auto',
preciseMouseInput: false,
// todo ui setting, maybe enable by default?
waitForChunksRender: false as 'sp-only' | boolean,
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
modsSupport: false,
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
modsUpdatePeriodCheck: 24, // hours
preventBackgroundTimeoutKick: false,
preventSleep: false,
debugContro: false,
debugChatScroll: false,
chatVanillaRestrictions: true,
debugResponseTimeIndicator: false,
chatPingExtension: true,
// antiAliasing: false,
topRightTimeDisplay: 'only-fullscreen' as 'only-fullscreen' | 'always' | 'never',
clipWorldBelowY: undefined as undefined | number, // will be removed
disableSignsMapsSupport: false,
singleplayerAutoSave: false,
showChunkBorders: false, // todo rename option
frameLimit: false as number | false,
alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null,
alwaysShowMobileControls: false,
excludeCommunicationDebugEvents: [],
preventDevReloadWhilePlaying: false,
numWorkers: 4,
localServerOptions: {
gameMode: 1
} as any,
preferLoadReadonly: false,
disableLoadPrompts: false,
guestUsername: 'guest',
askGuestName: true,
errorReporting: true,
/** Actually might be useful */
showCursorBlockInSpectator: false,
renderEntities: true,
smoothLighting: true,
newVersionsLighting: false,
chatSelect: true,
autoJump: 'auto' as 'auto' | 'always' | 'never',
autoParkour: false,
vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users
vrPageGameRendering: false,
renderDebug: 'basic' as 'none' | 'advanced' | 'basic',
rendererPerfDebugOverlay: false,
// advanced bot options
autoRespawn: false,
mutedSounds: [] as string[],
plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>,
/** Wether to popup sign editor on server action */
autoSignEditor: true,
wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never',
showMinimap: 'never' as 'always' | 'singleplayer' | 'never',
minimapOptimizations: true,
displayBossBars: true,
disabledUiParts: [] as string[],
neighborChunkUpdates: true,
highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic',
activeRenderer: 'threejs',
rendererSharedOptions: {
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
}
}
function getDefaultTouchControlsPositions () {
return {
action: [
70,
76
],
sneak: [
84,
76
],
break: [
70,
57
],
jump: [
84,
57
],
} as Record<string, [number, number]>
}
function getTouchControlsSize () {
return {
joystick: 55,
action: 36,
break: 36,
jump: 36,
sneak: 36,
}
}

View file

@ -46,6 +46,8 @@ export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ re
activeModalStack.push(resolved)
}
window.showModal = showModal
/**
*
* @returns true if previous modal was restored

View file

@ -159,7 +159,9 @@ const getImage = ({ path = undefined as string | undefined, texture = undefined
if (image) {
return image
}
if (!path && !texture) throw new Error('Either pass path or texture')
if (!path && !texture) {
throw new Error('Either pass path or texture')
}
const loadPath = (blockData ? 'blocks' : path ?? texture)!
if (loadedImagesCache.has(loadPath)) {
onLoad()
@ -201,6 +203,11 @@ const itemToVisualKey = (slot: RenderItem | Item | null) => {
].join('|')
return keys
}
const validateSlot = (slot: any, index: number) => {
if (!slot.texture) {
throw new Error(`Slot has no texture: ${index} ${slot.name}`)
}
}
const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
const newSlots = slots.map((slot, i) => {
if (!slot) return null
@ -210,6 +217,7 @@ const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
const newKey = itemToVisualKey(slot)
slot['cacheKey'] = i + '|' + newKey
if (oldKey && oldKey === newKey) {
validateSlot(lastMappedSlots[i], i)
return lastMappedSlots[i]
}
}
@ -228,12 +236,13 @@ const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
const { icon, ...rest } = slot
return rest
}
validateSlot(slot, i)
} catch (err) {
inGameError(err)
}
return slot
})
lastMappedSlots = newSlots
lastMappedSlots = JSON.parse(JSON.stringify(newSlots))
return newSlots
}

View file

@ -85,7 +85,6 @@ export const loadSave = async (root = '/world', connectOptions?: Partial<Connect
}
let version: string | undefined | null
let isFlat = false
if (levelDat) {
version = appQueryParams.mapVersion ?? levelDat.Version?.Name
if (!version) {
@ -103,21 +102,6 @@ export const loadSave = async (root = '/world', connectOptions?: Partial<Connect
version = prompt(`Version ${version} is not supported, supported versions are ${supportedVersions.join(', ')}, what try to use instead?`, lowerBound ? firstSupportedVersion : lastSupportedVersion)
if (!version) return
}
if (levelDat.WorldGenSettings) {
for (const [key, value] of Object.entries(levelDat.WorldGenSettings.dimensions)) {
if (key.slice(10) === 'overworld') {
if (value.generator.type === 'flat') isFlat = true
break
}
}
}
if (levelDat.generatorName) {
isFlat = levelDat.generatorName === 'flat'
}
if (!isFlat && levelDat.generatorName !== 'default' && levelDat.generatorName !== 'customized') {
// warnings.push(`Generator ${levelDat.generatorName} may not be supported yet, be careful of new chunks writes`)
}
const playerUuid = nameToMcOfflineUUID(options.localUsername)
const playerDatPath = `${root}/playerdata/${playerUuid}.dat`
@ -188,11 +172,6 @@ export const loadSave = async (root = '/world', connectOptions?: Partial<Connect
// todo check gamemode level.dat data etc
detail: {
version,
...isFlat ? {
generation: {
name: 'superflat'
}
} : {},
...root === '/world' ? {} : {
'worldFolder': root
},

View file

@ -20,6 +20,7 @@ import { getVersionAutoSelect } from './connect'
import { createNotificationProgressReporter } from './core/progressReporter'
import { customKeymaps } from './controls'
import { appStorage } from './react/appStorageProvider'
import { exportData, importData } from './core/importExport'
export const guiOptionsScheme: {
[t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial<OptionMeta<AppOptions[K]>> } & { custom? }>
@ -532,6 +533,30 @@ export const guiOptionsScheme: {
return <Button label='Export/Import...' onClick={() => openOptionsMenu('export-import')} inScreen />
}
},
{
custom () {
const { cookieStorage } = useSnapshot(appStorage)
return <Button
label={`Storage: ${cookieStorage ? 'Synced Cookies' : 'Local Storage'}`} onClick={() => {
appStorage.cookieStorage = !cookieStorage
alert('Reload the page to apply this change')
}}
inScreen
/>
}
},
{
custom () {
const { cookieStorage } = useSnapshot(appStorage)
return <Button
label={`Storage: ${cookieStorage ? 'Synced Cookies' : 'Local Storage'}`} onClick={() => {
appStorage.cookieStorage = !cookieStorage
alert('Reload the page to apply this change')
}}
inScreen
/>
}
},
{
custom () {
return <Category>Server Connection</Category>
@ -637,8 +662,7 @@ export const guiOptionsScheme: {
custom () {
return <Button
inScreen
disabled={true}
onClick={() => {}}
onClick={importData}
>Import Data</Button>
}
},
@ -646,53 +670,7 @@ export const guiOptionsScheme: {
custom () {
return <Button
inScreen
onClick={async () => {
const data = await showInputsModal('Export Profile', {
profileName: {
type: 'text',
},
exportSettings: {
type: 'checkbox',
defaultValue: true,
},
exportKeybindings: {
type: 'checkbox',
defaultValue: true,
},
exportServers: {
type: 'checkbox',
defaultValue: true,
},
saveUsernameAndProxy: {
type: 'checkbox',
defaultValue: true,
},
})
const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json`
const json = {
_about: 'Minecraft Web Client (mcraft.fun) Profile',
...data.exportSettings ? {
options: getChangedSettings(),
} : {},
...data.exportKeybindings ? {
keybindings: customKeymaps,
} : {},
...data.exportServers ? {
servers: appStorage.serversList,
} : {},
...data.saveUsernameAndProxy ? {
username: appStorage.username,
proxy: appStorage.proxiesData?.selected,
} : {},
}
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}}
onClick={exportData}
>Export Data</Button>
}
},

View file

@ -5,161 +5,10 @@ import { appQueryParams, appQueryParamsArray } from './appParams'
import type { AppConfig } from './appConfig'
import { appStorage } from './react/appStorageProvider'
import { miscUiState } from './globalState'
import { defaultOptions } from './defaultOptions'
const isDev = process.env.NODE_ENV === 'development'
const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {}
const defaultOptions = {
renderDistance: 3,
keepChunksDistance: 1,
multiplayerRenderDistance: 3,
closeConfirmation: true,
autoFullScreen: false,
mouseRawInput: true,
autoExitFullscreen: false,
localUsername: 'wanderer',
mouseSensX: 50,
mouseSensY: -1,
chatWidth: 320,
chatHeight: 180,
chatScale: 100,
chatOpacity: 100,
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
// fov: 70,
fov: 75,
guiScale: 3,
autoRequestCompletions: true,
touchButtonsSize: 40,
touchButtonsOpacity: 80,
touchButtonsPosition: 12,
touchControlsPositions: getDefaultTouchControlsPositions(),
touchControlsSize: getTouchControlsSize(),
touchMovementType: 'modern' as 'modern' | 'classic',
touchInteractionType: 'classic' as 'classic' | 'buttons',
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
backgroundRendering: '20fps' as 'full' | '20fps' | '5fps',
/** @unstable */
disableAssets: false,
/** @unstable */
debugLogNotFrequentPackets: false,
unimplementedContainers: false,
dayCycleAndLighting: true,
loadPlayerSkins: true,
renderEars: true,
lowMemoryMode: false,
starfieldRendering: true,
enabledResourcepack: null as string | null,
useVersionsTextures: 'latest',
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
showHand: true,
viewBobbing: true,
displayRecordButton: true,
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
customChannels: false,
remoteContentNotSameOrigin: false as boolean | string[],
packetsRecordingAutoStart: false,
language: 'auto',
preciseMouseInput: false,
// todo ui setting, maybe enable by default?
waitForChunksRender: false as 'sp-only' | boolean,
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
modsSupport: false,
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
modsUpdatePeriodCheck: 24, // hours
preventBackgroundTimeoutKick: false,
preventSleep: false,
debugContro: false,
debugChatScroll: false,
chatVanillaRestrictions: true,
debugResponseTimeIndicator: false,
chatPingExtension: true,
// antiAliasing: false,
topRightTimeDisplay: 'only-fullscreen' as 'only-fullscreen' | 'always' | 'never',
clipWorldBelowY: undefined as undefined | number, // will be removed
disableSignsMapsSupport: false,
singleplayerAutoSave: false,
showChunkBorders: false, // todo rename option
frameLimit: false as number | false,
alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null,
alwaysShowMobileControls: false,
excludeCommunicationDebugEvents: [],
preventDevReloadWhilePlaying: false,
numWorkers: 4,
localServerOptions: {
gameMode: 1
} as any,
preferLoadReadonly: false,
disableLoadPrompts: false,
guestUsername: 'guest',
askGuestName: true,
errorReporting: true,
/** Actually might be useful */
showCursorBlockInSpectator: false,
renderEntities: true,
smoothLighting: true,
newVersionsLighting: false,
chatSelect: true,
autoJump: 'auto' as 'auto' | 'always' | 'never',
autoParkour: false,
vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users
vrPageGameRendering: false,
renderDebug: 'basic' as 'none' | 'advanced' | 'basic',
rendererPerfDebugOverlay: false,
// advanced bot options
autoRespawn: false,
mutedSounds: [] as string[],
plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>,
/** Wether to popup sign editor on server action */
autoSignEditor: true,
wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never',
showMinimap: 'never' as 'always' | 'singleplayer' | 'never',
minimapOptimizations: true,
displayBossBars: true,
disabledUiParts: [] as string[],
neighborChunkUpdates: true,
highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic',
activeRenderer: 'threejs',
rendererSharedOptions: {
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
}
}
function getDefaultTouchControlsPositions () {
return {
action: [
70,
76
],
sneak: [
84,
76
],
break: [
70,
57
],
jump: [
84,
57
],
} as Record<string, [number, number]>
}
function getTouchControlsSize () {
return {
joystick: 55,
action: 36,
break: 36,
jump: 36,
sneak: 36,
}
}
// const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting')
const qsOptionsRaw = appQueryParamsArray.setting ?? []
@ -191,15 +40,15 @@ const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
return options
}
const migrateOptionsLocalStorage = () => {
if (Object.keys(appStorage.options).length) {
for (const key of Object.keys(appStorage.options)) {
if (Object.keys(appStorage['options'] ?? {}).length) {
for (const key of Object.keys(appStorage['options'])) {
if (!(key in defaultOptions)) continue // drop unknown options
const defaultValue = defaultOptions[key]
if (JSON.stringify(defaultValue) !== JSON.stringify(appStorage.options[key])) {
appStorage.changedSettings[key] = appStorage.options[key]
if (JSON.stringify(defaultValue) !== JSON.stringify(appStorage['options'][key])) {
appStorage.changedSettings[key] = appStorage['options'][key]
}
}
appStorage.options = {}
delete appStorage['options']
}
}
@ -311,3 +160,5 @@ export const getAppLanguage = () => {
}
return options.language
}
export { defaultOptions } from './defaultOptions'

View file

@ -189,23 +189,22 @@ input[type=text],
background-color: rgba(0, 0, 0, 0.5);
list-style: none;
overflow-wrap: break-word;
}
.chat-message-fadeout {
opacity: 1;
transition: all 3s;
}
.chat-message-fade {
.chat-message-fading {
opacity: 0;
transition: opacity 3s ease-in-out;
}
.chat-message-faded {
transition: none !important;
display: none;
}
/* Ensure messages are always visible when chat is open */
.chat.opened .chat-message {
opacity: 1 !important;
display: block !important;
transition: none !important;
}

View file

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'
import { useEffect, useState } from 'react'
import { formatMessage } from '../chatUtils'
import Chat, { fadeMessage, chatInputValueGlobal } from './Chat'
import Chat, { chatInputValueGlobal } from './Chat'
import Button from './Button'
window.spamMessage = window.spamMessage ?? ''
@ -63,14 +63,6 @@ const meta: Meta<typeof Chat> = {
return () => clearInterval(interval)
}, [autoSpam])
const fadeMessages = () => {
for (const m of messages) {
fadeMessage(m, false, () => {
setMessages([...messages])
})
}
}
return <div style={{
marginTop: args.usingTouch ? 100 : 0
}}
@ -88,7 +80,6 @@ const meta: Meta<typeof Chat> = {
}}
/>
<Button onClick={() => setOpen(s => !s)}>Open: {open ? 'on' : 'off'}</Button>
<Button onClick={() => fadeMessages()}>Fade</Button>
<Button onClick={() => setAutoSpam(s => !s)}>Auto Spam: {autoSpam ? 'on' : 'off'}</Button>
<Button onClick={() => setMessages(args.messages)}>Reset</Button>
</div>

View file

@ -11,19 +11,37 @@ import { useScrollBehavior } from './hooks/useScrollBehavior'
export type Message = {
parts: MessageFormatPart[],
id: number
fading?: boolean
faded?: boolean
timestamp?: number
}
const MessageLine = ({ message, currentPlayerName }: { message: Message, currentPlayerName?: string }) => {
const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Message, currentPlayerName?: string, chatOpened?: boolean }) => {
const [fadeState, setFadeState] = useState<'visible' | 'fading' | 'faded'>('visible')
useEffect(() => {
// Start fading after 5 seconds
const fadeTimeout = setTimeout(() => {
setFadeState('fading')
}, 5000)
// Remove after fade animation (3s) completes
const removeTimeout = setTimeout(() => {
setFadeState('faded')
}, 8000)
// Cleanup timeouts if component unmounts
return () => {
clearTimeout(fadeTimeout)
clearTimeout(removeTimeout)
}
}, []) // Empty deps array since we only want this to run once when message is added
const classes = {
'chat-message-fadeout': message.fading,
'chat-message-fade': message.fading,
'chat-message-faded': message.faded,
'chat-message': true
'chat-message': true,
'chat-message-fading': !chatOpened && fadeState === 'fading',
'chat-message-faded': !chatOpened && fadeState === 'faded'
}
return <li className={Object.entries(classes).filter(([, val]) => val).map(([name]) => name).join(' ')}>
return <li className={Object.entries(classes).filter(([, val]) => val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}>
{message.parts.map((msg, i) => {
// Check if this is a text part that might contain a mention
if (msg.text && currentPlayerName) {
@ -70,17 +88,6 @@ export const chatInputValueGlobal = proxy({
value: ''
})
export const fadeMessage = (message: Message, initialTimeout: boolean, requestUpdate: () => void) => {
setTimeout(() => {
message.fading = true
requestUpdate()
setTimeout(() => {
message.faded = true
requestUpdate()
}, 3000)
}, initialTimeout ? 5000 : 0)
}
export default ({
messages,
opacity = 1,
@ -372,7 +379,7 @@ export default ({
</div>
)}
{messages.map((m) => (
<MessageLine key={reactKeyForMessage(m)} message={m} currentPlayerName={playerNameValidated} />
<MessageLine key={reactKeyForMessage(m)} message={m} currentPlayerName={playerNameValidated} chatOpened={opened} />
))}
</div> || undefined}
</div>

View file

@ -5,7 +5,7 @@ import { getBuiltinCommandsList, tryHandleBuiltinCommand } from '../builtinComma
import { gameAdditionalState, hideCurrentModal, miscUiState } from '../globalState'
import { options } from '../optionsStorage'
import { viewerVersionState } from '../viewerConnector'
import Chat, { Message, fadeMessage } from './Chat'
import Chat, { Message } from './Chat'
import { useIsModalActive } from './utilsApp'
import { hideNotification, notificationProxy, showNotification } from './NotificationProvider'
import { getServerIndex, updateLoadedServerData } from './serversStorage'
@ -16,6 +16,7 @@ export default () => {
const [messages, setMessages] = useState([] as Message[])
const isChatActive = useIsModalActive('chat')
const lastMessageId = useRef(0)
const lastPingTime = useRef(0)
const usingTouch = useSnapshot(miscUiState).currentTouch
const { chatSelect, messagesLimit, chatOpacity, chatOpacityOpened, chatVanillaRestrictions, debugChatScroll, chatPingExtension } = useSnapshot(options)
const isUsingMicrosoftAuth = useMemo(() => !!lastConnectOptions.value?.authenticatedAccount, [])
@ -29,18 +30,23 @@ export default () => {
jsonMsg = jsonMsg['unsigned']
}
const parts = formatMessage(jsonMsg)
const messageText = parts.map(part => part.text).join('')
// Handle ping response
if (messageText === 'Pong!' && lastPingTime.current > 0) {
const latency = Date.now() - lastPingTime.current
parts.push({ text: ` Latency: ${latency}ms`, color: '#00ff00' })
lastPingTime.current = 0
}
setMessages(m => {
lastMessageId.current++
const newMessage: Message = {
parts,
id: lastMessageId.current,
faded: false,
timestamp: Date.now()
}
fadeMessage(newMessage, true, () => {
// eslint-disable-next-line max-nested-callbacks
setMessages(m => [...m])
})
return [...m, newMessage].slice(-messagesLimit)
})
})
@ -61,6 +67,11 @@ export default () => {
return players.filter(name => (!value || name.toLowerCase().includes(value.toLowerCase())) && name !== bot.username).map(name => `@${name}`)
}}
sendMessage={async (message) => {
// Record ping command time
if (message === '/ping') {
lastPingTime.current = Date.now()
}
const builtinHandled = tryHandleBuiltinCommand(message)
if (getServerIndex() !== undefined && (message.startsWith('/login') || message.startsWith('/register'))) {
showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, () => {

View file

@ -11,7 +11,7 @@ import styles from './createWorld.module.css'
import { InputOption, showInputsModal, showOptionsModal } from './SelectOption'
// const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states']
const worldTypes = ['default', 'flat'/* , 'void' */]
const worldTypes = ['default', 'flat', 'empty', 'nether', 'all_the_blocks']
const gameModes = ['survival', 'creative'/* , 'adventure', 'spectator' */]
export const creatingWorldState = proxy({

View file

@ -32,30 +32,14 @@ export default () => {
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
await mkdirRecursive(savePath)
await loadPluginsIntoWorld(savePath, plugins)
let generation
if (type === 'flat') {
generation = {
name: 'superflat',
}
}
if (type === 'void') {
generation = {
name: 'superflat',
layers: [],
noDefaults: true
}
}
if (type === 'nether') {
generation = {
name: 'nether'
}
}
hideCurrentModal()
window.dispatchEvent(new CustomEvent('singleplayer', {
detail: {
levelName: title,
version,
generation,
generation: {
name: type
},
'worldFolder': savePath,
gameMode: gameMode === 'survival' ? 0 : 1,
},

View file

@ -189,18 +189,16 @@ interface Props {
}
export default ({ items, title, backButtonAction }: Props) => {
const { currentTouch } = useSnapshot(miscUiState)
return <Screen
title={title}
>
<div className='screen-items'>
{currentTouch && (
<div style={{ position: 'fixed', marginLeft: '-30px', display: 'flex', flexDirection: 'column', gap: 1, }}>
<Button icon={pixelartIcons['close']} onClick={hideAllModals} style={{ color: '#ff5d5d', }} />
<Button icon={pixelartIcons['chevron-left']} onClick={backButtonAction} style={{ color: 'yellow', }} />
</div>
)}
<div style={{ position: 'fixed', marginLeft: '-30px', display: 'flex', flexDirection: 'column', gap: 1, }}>
<Button icon={pixelartIcons['close']} onClick={hideAllModals} style={{ color: '#ff5d5d', }} />
<Button icon={pixelartIcons['chevron-left']} onClick={backButtonAction} style={{ color: 'yellow', }} />
</div>
{items.map((element, i) => {
// make sure its unique!
return <RenderOption key={element.id ?? `${title}-${i}`} item={element} />

View file

@ -0,0 +1,98 @@
import { useSnapshot } from 'valtio'
import { activeModalStack, hideCurrentModal } from '../globalState'
import { resolveStorageConflicts, getStorageConflicts } from './appStorageProvider'
import { useIsModalActive } from './utilsApp'
import Screen from './Screen'
import Button from './Button'
const formatTimestamp = (timestamp?: number) => {
if (!timestamp) return 'Unknown time'
return new Date(timestamp).toLocaleString()
}
export default () => {
const isModalActive = useIsModalActive('storage-conflict')
const conflicts = getStorageConflicts()
if (!isModalActive/* || conflicts.length === 0 */) return null
const conflictText = conflicts.map(conflict => {
const localTime = formatTimestamp(conflict.localStorageTimestamp)
const cookieTime = formatTimestamp(conflict.cookieTimestamp)
return `${conflict.key}: LocalStorage (${localTime}) vs Cookie (${cookieTime})`
}).join('\n')
return (
<div
>
<div style={{
background: '#dcb58f',
border: '2px solid #654321',
padding: '20px',
margin: '10px',
color: '#FFFFFF',
fontFamily: 'minecraft, monospace',
textAlign: 'center',
zIndex: 1000,
position: 'fixed',
left: 0,
right: 0
}}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: '#000000',
marginBottom: '15px'
}}>
Data Conflict Found
</div>
<div style={{
fontSize: '12px',
marginBottom: '20px',
whiteSpace: 'pre-line',
// backgroundColor: 'rgba(0, 0, 0, 0.5)',
color: '#642323',
padding: '10px',
// border: '1px solid #654321'
}}>
You have conflicting data between localStorage (old) and cookies (new, domain-synced) for the following settings:
{'\n\n'}
{conflictText}
{'\n\n'}
Please choose which version to keep:
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center', fontSize: '8px', color: 'black' }}>
<div
onClick={() => {
resolveStorageConflicts(true) // Use localStorage
hideCurrentModal()
}}
style={{
border: '1px solid #654321',
padding: '8px 16px',
cursor: 'pointer'
}}
>
Use Local Storage & Disable Cookie Sync
</div>
<div
onClick={() => {
resolveStorageConflicts(false) // Use cookies
hideCurrentModal()
}}
style={{
border: '1px solid #654321',
padding: '8px 16px',
cursor: 'pointer'
}}
>
Use Cookie Data & Remove Local Data
</div>
</div>
</div>
</div>
)
}

View file

@ -8,7 +8,9 @@ import type { BaseServerInfo } from './AddServerOrConnect'
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
const cookiePrefix = ''
const { localStorage } = window
const migrateRemoveLocalStorage = false
export interface SavedProxiesData {
proxies: string[]
@ -31,12 +33,19 @@ export interface StoreServerItem extends BaseServerInfo {
isRecommended?: boolean
}
interface StorageConflict {
key: string
localStorageValue: any
localStorageTimestamp?: number
cookieValue: any
cookieTimestamp?: number
}
type StorageData = {
cookieStorage: boolean | { ignoreKeys: Array<keyof StorageData> }
customCommands: Record<string, CustomCommand> | undefined
username: string | undefined
keybindings: UserOverridesConfig | undefined
/** @deprecated */
options: any
changedSettings: any
proxiesData: SavedProxiesData | undefined
serversHistory: ServerHistoryEntry[]
@ -46,10 +55,115 @@ type StorageData = {
firstModsPageVisit: boolean
}
const cookieStoreKeys: Array<keyof StorageData> = [
'customCommands',
'username',
'keybindings',
'changedSettings',
'serversList',
]
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
serversHistory: 'serverConnectionHistory',
}
// Cookie storage functions
const getCookieValue = (key: string): string | null => {
const cookie = document.cookie.split(';').find(c => c.trimStart().startsWith(`${cookiePrefix}${key}=`))
if (cookie) {
return decodeURIComponent(cookie.split('=')[1])
}
return null
}
const topLevelDomain = window.location.hostname.split('.').slice(-2).join('.')
const cookieBase = `; Domain=.${topLevelDomain}; Path=/; SameSite=Strict; Secure`
const setCookieValue = (key: string, value: string): boolean => {
try {
const cookieKey = `${cookiePrefix}${key}`
let cookie = `${cookieKey}=${encodeURIComponent(value)}`
cookie += `${cookieBase}; Max-Age=2147483647`
// Test if cookie exceeds size limit
if (cookie.length > 4096) {
throw new Error(`Cookie size limit exceeded for key '${key}'. Cookie size: ${cookie.length} bytes, limit: 4096 bytes.`)
}
document.cookie = cookie
return true
} catch (error) {
console.error(`Failed to set cookie for key '${key}':`, error)
window.showNotification(`Failed to save data to cookies: ${error.message}`, 'Consider switching to localStorage in advanced settings.', true)
return false
}
}
const deleteCookie = (key: string) => {
const cookieKey = `${cookiePrefix}${key}`
document.cookie = `${cookieKey}=; ${cookieBase}; expires=Thu, 01 Jan 1970 00:00:00 UTC;`
}
// Storage conflict detection and resolution
let storageConflicts: StorageConflict[] = []
const detectStorageConflicts = (): StorageConflict[] => {
const conflicts: StorageConflict[] = []
for (const key of cookieStoreKeys) {
const localStorageKey = `${localStoragePrefix}${key}`
const localStorageValue = localStorage.getItem(localStorageKey)
const cookieValue = getCookieValue(key)
if (localStorageValue && cookieValue) {
try {
const localParsed = JSON.parse(localStorageValue)
const cookieParsed = JSON.parse(cookieValue)
if (localStorage.getItem(`${localStorageKey}:migrated`)) {
continue
}
// Extract timestamps if they exist
const localTimestamp = localParsed?.timestamp
const cookieTimestamp = cookieParsed?.timestamp
// Compare the actual data (excluding timestamp)
const localData = localTimestamp ? { ...localParsed } : localParsed
const cookieData = cookieTimestamp ? { ...cookieParsed } : cookieParsed
delete localData.timestamp
delete cookieData.timestamp
if (JSON.stringify(localData) !== JSON.stringify(cookieData)) {
conflicts.push({
key,
localStorageValue: localData,
localStorageTimestamp: localTimestamp,
cookieValue: cookieData,
cookieTimestamp
})
}
} catch (e) {
console.error(`Failed to parse storage values for conflict detection on key '${key}':`, e, localStorageValue, cookieValue)
}
}
}
return conflicts
}
const showStorageConflictModal = () => {
// Import showModal dynamically to avoid circular dependency
const showModal = (window as any).showModal || ((modal: any) => {
console.error('Modal system not available:', modal)
console.warn('Storage conflicts detected but modal system not available:', storageConflicts)
})
setTimeout(() => {
showModal({ reactType: 'storage-conflict', conflicts: storageConflicts })
}, 100)
}
const migrateLegacyData = () => {
const proxies = localStorage.getItem('proxies')
const selectedProxy = localStorage.getItem('selectedProxy')
@ -75,10 +189,10 @@ const migrateLegacyData = () => {
}
const defaultStorageData: StorageData = {
cookieStorage: !!process.env.ENABLE_COOKIE_STORAGE && !process.env?.SINGLE_FILE_BUILD,
customCommands: undefined,
username: undefined,
keybindings: undefined,
options: {},
changedSettings: {},
proxiesData: undefined,
serversHistory: [],
@ -108,29 +222,134 @@ export const getRandomUsername = (appConfig: AppConfig) => {
export const appStorage = proxy({ ...defaultStorageData })
// Restore data from localStorage
for (const key of Object.keys(defaultStorageData)) {
const prefixedKey = `${localStoragePrefix}${key}`
const aliasedKey = oldKeysAliases[key]
const storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : undefined)
if (storedValue) {
try {
const parsed = JSON.parse(storedValue)
// appStorage[key] = parsed && typeof parsed === 'object' ? ref(parsed) : parsed
appStorage[key] = parsed
} catch (e) {
console.error(`Failed to parse stored value for ${key}:`, e)
// Check if cookie storage should be used (will be set by options)
const shouldUseCookieStorage = () => {
const isSecureCookiesAvailable = () => {
// either https or localhost
return window.location.protocol === 'https:' || window.location.hostname === 'localhost'
}
if (!isSecureCookiesAvailable()) {
return false
}
const localStorageValue = localStorage.getItem(`${localStoragePrefix}cookieStorage`)
if (localStorageValue === null) {
return appStorage.cookieStorage === true
}
return localStorageValue === 'true'
}
// Restore data from storage with conflict detection
const restoreStorageData = () => {
const useCookieStorage = shouldUseCookieStorage()
if (useCookieStorage) {
// Detect conflicts first
storageConflicts = detectStorageConflicts()
if (storageConflicts.length > 0) {
// Show conflict resolution modal
showStorageConflictModal()
return // Don't restore data until conflict is resolved
}
}
for (const key of Object.keys(defaultStorageData)) {
const typedKey = key
const prefixedKey = `${localStoragePrefix}${key}`
const aliasedKey = oldKeysAliases[typedKey]
let storedValue: string | null = null
let cookieValueCanBeUsed = false
let usingLocalStorageValue = false
// Try cookie storage first if enabled and key is in cookieStoreKeys
if (useCookieStorage && cookieStoreKeys.includes(typedKey)) {
storedValue = getCookieValue(key)
cookieValueCanBeUsed = true
}
// Fallback to localStorage if no cookie value found
if (storedValue === null) {
storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : null)
usingLocalStorageValue = true
}
if (storedValue) {
try {
let parsed = JSON.parse(storedValue)
// Handle timestamped data
if (parsed && typeof parsed === 'object' && parsed.timestamp) {
delete parsed.timestamp
// If it was a wrapped primitive, unwrap it
if ('data' in parsed && Object.keys(parsed).length === 1) {
parsed = parsed.data
}
}
appStorage[typedKey] = parsed
if (usingLocalStorageValue && cookieValueCanBeUsed) {
// migrate localStorage to cookie
saveKey(key)
markLocalStorageAsMigrated(key)
}
} catch (e) {
console.error(`Failed to parse stored value for ${key}:`, e)
}
}
}
}
const markLocalStorageAsMigrated = (key: keyof StorageData) => {
const localStorageKey = `${localStoragePrefix}${key}`
if (migrateRemoveLocalStorage) {
localStorage.removeItem(localStorageKey)
return
}
localStorage.setItem(`${localStorageKey}:migrated`, 'true')
}
const saveKey = (key: keyof StorageData) => {
const useCookieStorage = shouldUseCookieStorage()
const prefixedKey = `${localStoragePrefix}${key}`
const value = appStorage[key]
if (value === undefined) {
localStorage.removeItem(prefixedKey)
} else {
localStorage.setItem(prefixedKey, JSON.stringify(value))
const dataToSave = value === undefined ? undefined : (
value && typeof value === 'object' && !Array.isArray(value)
? { ...value, timestamp: Date.now() }
: { data: value, timestamp: Date.now() }
)
const serialized = dataToSave === undefined ? undefined : JSON.stringify(dataToSave)
let useLocalStorage = true
// Save to cookie if enabled and key is in cookieStoreKeys
if (useCookieStorage && cookieStoreKeys.includes(key)) {
useLocalStorage = false
if (serialized === undefined) {
deleteCookie(key)
} else {
const success = setCookieValue(key, serialized)
if (success) {
// Remove from localStorage if cookie save was successful
markLocalStorageAsMigrated(key)
} else {
// Disabling for now so no confusing conflicts modal after page reload
// useLocalStorage = true
}
}
}
if (useLocalStorage) {
// Save to localStorage
if (value === undefined) {
localStorage.removeItem(prefixedKey)
} else {
localStorage.setItem(prefixedKey, JSON.stringify(value))
}
}
}
@ -141,7 +360,6 @@ subscribe(appStorage, (ops) => {
saveKey(key as keyof StorageData)
}
})
// Subscribe to changes and save to localStorage
export const resetAppStorage = () => {
for (const key of Object.keys(appStorage)) {
@ -153,6 +371,38 @@ export const resetAppStorage = () => {
localStorage.removeItem(key)
}
}
if (!shouldUseCookieStorage()) return
const shouldContinue = window.confirm(`Removing all synced cookies will remove all data from all ${topLevelDomain} subdomains websites. Continue?`)
if (!shouldContinue) return
// Clear cookies
for (const key of cookieStoreKeys) {
deleteCookie(key)
}
}
// Export functions for conflict resolution
export const resolveStorageConflicts = (useLocalStorage: boolean) => {
if (useLocalStorage) {
// Disable cookie storage and use localStorage data
appStorage.cookieStorage = false
} else {
// Remove localStorage data and continue using cookie storage
for (const conflict of storageConflicts) {
const prefixedKey = `${localStoragePrefix}${conflict.key}`
localStorage.removeItem(prefixedKey)
}
}
// Clear conflicts and restore data
storageConflicts = []
restoreStorageData()
}
export const getStorageConflicts = () => storageConflicts
migrateLegacyData()
// Restore data after checking for conflicts
restoreStorageData()

View file

@ -65,6 +65,7 @@ import RendererDebugMenu from './react/RendererDebugMenu'
import CreditsAboutModal from './react/CreditsAboutModal'
import GlobalOverlayHints from './react/GlobalOverlayHints'
import FullscreenTime from './react/FullscreenTime'
import StorageConflictModal from './react/StorageConflictModal'
const isFirefox = ua.getBrowser().name === 'Firefox'
if (isFirefox) {
@ -228,6 +229,7 @@ const App = () => {
<div />
</RobustPortal>
<EnterFullscreenButton />
<StorageConflictModal />
<InGameUi />
<RobustPortal to={document.querySelector('#ui-root')}>
<AllWidgets />

View file

@ -136,6 +136,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
let lastStepSound = 0
const movementHappening = async () => {
if (!bot.entity || !soundMap) return // no info yet
if (appViewer.playerState.reactive.gameMode === 'spectator') return // Don't play step sounds in spectator mode
const VELOCITY_THRESHOLD = 0.1
const RUN_THRESHOLD = 0.15
const { x, z, y } = bot.entity.velocity