Release (#378)
This commit is contained in:
commit
226e4be4de
25 changed files with 915 additions and 352 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
36
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
219
src/core/importExport.ts
Normal 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
152
src/defaultOptions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
98
src/react/StorageConflictModal.tsx
Normal file
98
src/react/StorageConflictModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue