//@ts-check import { Vec3 } from 'vec3' import { proxy, subscribe } from 'valtio' import { ControMax } from 'contro-max/build/controMax' import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types' import { stringStartsWith } from 'contro-max/build/stringUtils' import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store' import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState } from './globalState' import { goFullscreen, pointerLock, reloadChunks } from './utils' import { options } from './optionsStorage' import { openPlayerInventory } from './inventoryWindows' import { chatInputValueGlobal } from './react/Chat' import { fsState } from './loadSave' import { customCommandsConfig } from './customCommands' import type { CustomCommand } from './react/KeybindingsCustom' import { showOptionsModal } from './react/SelectOption' import widgets from './react/widgets' import { getItemFromBlock } from './chatUtils' import { gamepadUiCursorState, moveGamepadCursorByPx } from './react/GamepadUiCursor' import { completeTexturePackInstall, resourcePackState } from './resourcePack' import { showNotification } from './react/NotificationProvider' import { lastConnectOptions } from './react/AppStatusProvider' export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) as UserOverridesConfig subscribe(customKeymaps, () => { localStorage.keymap = JSON.stringify(customKeymaps) }) const controlOptions = { preventDefault: true } export const contro = new ControMax({ commands: { general: { jump: ['Space', 'A'], inventory: ['KeyE', 'X'], drop: ['KeyQ', 'B'], sneak: ['ShiftLeft'], toggleSneakOrDown: [null, 'Right Stick'], sprint: ['ControlLeft', 'Left Stick'], nextHotbarSlot: [null, 'Right Bumper'], prevHotbarSlot: [null, 'Left Bumper'], attackDestroy: [null, 'Right Trigger'], interactPlace: [null, 'Left Trigger'], chat: [['KeyT', 'Enter']], command: ['Slash'], swapHands: ['KeyF'], selectItem: ['KeyH'] // default will be removed }, ui: { toggleFullscreen: ['F11'], back: [null/* 'Escape' */, 'B'], leftClick: [null, 'A'], rightClick: [null, 'Y'], speedupCursor: [null, 'Left Stick'], pauseMenu: [null, 'Start'] }, advanced: { lockUrl: ['KeyY'], }, custom: {} as Record, // waila: { // showLookingBlockRecipe: ['Numpad3'], // showLookingBlockUsages: ['Numpad4'] // } } satisfies Record>, movementKeymap: 'WASD', movementVector: '2d', groupedCommands: { general: { switchSlot: ['Digits', []] } }, }, { defaultControlOptions: controlOptions, target: document, captureEvents () { return true }, storeProvider: { load: () => customKeymaps, save () { }, }, gamepadPollingInterval: 10 }) window.controMax = contro export type Command = CommandEventArgument['command'] updateBinds(customKeymaps) const updateDoPreventDefault = () => { controlOptions.preventDefault = miscUiState.gameLoaded && !activeModalStack.length } subscribe(miscUiState, updateDoPreventDefault) subscribe(activeModalStack, updateDoPreventDefault) updateDoPreventDefault() const setSprinting = (state: boolean) => { bot.setControlState('sprint', state) gameAdditionalState.isSprinting = state } contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => { if (gamepadIndex !== undefined && gamepadUiCursorState.display) { const deadzone = 0.1 // TODO make deadzone configurable if (Math.abs(soleVector.x) < deadzone && Math.abs(soleVector.z) < deadzone) { return } moveGamepadCursorByPx(soleVector.x, true) moveGamepadCursorByPx(soleVector.z, false) emitMousemove() } miscUiState.usingGamepadInput = gamepadIndex !== undefined if (!bot || !isGameActive(false)) return // gamepadIndex will be used for splitscreen in future const coordToAction = [ ['z', -1, 'forward'], ['z', 1, 'back'], ['x', -1, 'left'], ['x', 1, 'right'], ] as const const newState: Partial = {} for (const [coord, v] of Object.entries(vector)) { if (v === undefined || Math.abs(v) < 0.3) continue // todo use raw values eg for slow movement const mappedValue = v < 0 ? -1 : 1 // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain const foundAction = coordToAction.find(([c, mapV]) => c === coord && mapV === mappedValue)?.[2]! newState[foundAction] = true } for (const key of ['forward', 'back', 'left', 'right'] as const) { if (newState[key] === bot.controlState[key]) continue const action = !!newState[key] if (action && !isGameActive(true)) continue bot.setControlState(key, action) if (key === 'forward') { // todo workaround: need to refactor if (action) { void contro.emit('trigger', { command: 'general.forward' } as any) } else { setSprinting(false) } } } }) let lastCommandTrigger = null as { command: string, time: number } | null const secondActionActivationTimeout = 300 const secondActionCommands = { 'general.jump' () { // if (bot.game.gameMode === 'spectator') return toggleFly() }, 'general.forward' () { setSprinting(true) } } // detect pause open, as ANY keyup event is not fired when you exit pointer lock (esc) subscribe(activeModalStack, () => { if (activeModalStack.length) { // iterate over pressedKeys for (const key of contro.pressedKeys) { contro.pressedKeyOrButtonChanged({ code: key }, false) } } }) const emitMousemove = () => { const { x, y } = gamepadUiCursorState const xAbs = x / 100 * window.innerWidth const yAbs = y / 100 * window.innerHeight const element = document.elementFromPoint(xAbs, yAbs) as HTMLElement | null if (!element) return element.dispatchEvent(new MouseEvent('mousemove', { clientX: xAbs, clientY: yAbs })) } let lastClickedEl = null as HTMLElement | null let lastClickedElTimeout: ReturnType | undefined const inModalCommand = (command: Command, pressed: boolean) => { if (pressed && !gamepadUiCursorState.display) return if (pressed) { if (command === 'ui.back') { hideCurrentModal() } if (command === 'ui.leftClick' || command === 'ui.rightClick') { // in percent const { x, y } = gamepadUiCursorState const xAbs = x / 100 * window.innerWidth const yAbs = y / 100 * window.innerHeight const el = document.elementFromPoint(xAbs, yAbs) as HTMLElement if (el) { if (el === lastClickedEl && command === 'ui.leftClick') { el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: xAbs, clientY: yAbs })) return } el.dispatchEvent(new MouseEvent('mousedown', { button: command === 'ui.leftClick' ? 0 : 2, bubbles: true, clientX: xAbs, clientY: yAbs })) el.dispatchEvent(new MouseEvent(command === 'ui.leftClick' ? 'click' : 'contextmenu', { bubbles: true, clientX: xAbs, clientY: yAbs })) el.dispatchEvent(new MouseEvent('mouseup', { button: command === 'ui.leftClick' ? 0 : 2, bubbles: true, clientX: xAbs, clientY: yAbs })) el.focus() lastClickedEl = el if (lastClickedElTimeout) clearTimeout(lastClickedElTimeout) lastClickedElTimeout = setTimeout(() => { lastClickedEl = null }, 500) } } } if (command === 'ui.speedupCursor') { gamepadUiCursorState.multiply = pressed ? 2 : 1 } } const setSneaking = (state: boolean) => { gameAdditionalState.isSneaking = state bot.setControlState('sneak', state) } const onTriggerOrReleased = (command: Command, pressed: boolean) => { // always allow release! if (!bot || !isGameActive(false)) return if (stringStartsWith(command, 'general')) { // handle general commands // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (command) { case 'general.jump': bot.setControlState('jump', pressed) break case 'general.sneak': setSneaking(pressed) break case 'general.sprint': // todo add setting to change behavior if (pressed) { setSprinting(pressed) } break case 'general.toggleSneakOrDown': if (gameAdditionalState.isFlying) { setSneaking(pressed) } else if (pressed) { setSneaking(!gameAdditionalState.isSneaking) } break case 'general.attackDestroy': document.dispatchEvent(new MouseEvent(pressed ? 'mousedown' : 'mouseup', { button: 0 })) break case 'general.interactPlace': document.dispatchEvent(new MouseEvent(pressed ? 'mousedown' : 'mouseup', { button: 2 })) break } } } // im still not sure, maybe need to refactor to handle in inventory instead const alwaysPressedHandledCommand = (command: Command) => { inModalCommand(command, true) // triggered even outside of the game if (command === 'general.inventory') { if (activeModalStack.at(-1)?.reactType?.startsWith?.('player_win:')) { // todo? hideCurrentModal() } } if (command === 'advanced.lockUrl') { lockUrl() } } function lockUrl () { let newQs = '' if (fsState.saveLoaded) { const save = localServer!.options.worldFolder.split('/').at(-1) newQs = `loadSave=${save}` } else if (process.env.NODE_ENV === 'development') { newQs = `reconnect=1` } else if (lastConnectOptions.value?.server) { const qs = new URLSearchParams() const { server, botVersion } = lastConnectOptions.value qs.set('server', server) if (botVersion) qs.set('version', botVersion) newQs = String(qs.toString()) } if (newQs) { window.history.replaceState({}, '', `${window.location.pathname}?${newQs}`) } } function cycleHotbarSlot (dir: 1 | -1) { const newHotbarSlot = (bot.quickBarSlot + dir + 9) % 9 bot.setQuickBarSlot(newHotbarSlot) } // custom commands handler const customCommandsHandler = ({ command }) => { const [section, name] = command.split('.') if (!isGameActive(true) || section !== 'custom') return if (contro.userConfig?.custom) { customCommandsConfig[(contro.userConfig.custom[name] as CustomCommand).type].handler((contro.userConfig.custom[name] as CustomCommand).inputs) } } contro.on('trigger', customCommandsHandler) contro.on('trigger', ({ command }) => { const willContinue = !isGameActive(true) alwaysPressedHandledCommand(command) if (willContinue) return const secondActionCommand = secondActionCommands[command] if (secondActionCommand) { if (command === lastCommandTrigger?.command && Date.now() - lastCommandTrigger.time < secondActionActivationTimeout) { const commandToTrigger = secondActionCommands[lastCommandTrigger.command] commandToTrigger() lastCommandTrigger = null } else { lastCommandTrigger = { command, time: Date.now(), } } } onTriggerOrReleased(command, true) if (stringStartsWith(command, 'general')) { switch (command) { case 'general.jump': case 'general.sneak': case 'general.toggleSneakOrDown': case 'general.sprint': case 'general.attackDestroy': case 'general.swapHands': { bot._client.write('entity_action', { entityId: bot.entity.id, actionId: 6, jumpBoost: 0 }) break } case 'general.interactPlace': // handled in onTriggerOrReleased break case 'general.inventory': document.exitPointerLock?.() openPlayerInventory() break case 'general.drop': { // if (bot.heldItem/* && ctrl */) bot.tossStack(bot.heldItem) bot._client.write('block_dig', { 'status': 4, 'location': { 'x': 0, 'z': 0, 'y': 0 }, 'face': 0, sequence: 0 }) const slot = bot.inventory.hotbarStart + bot.quickBarSlot const item = bot.inventory.slots[slot] if (item) { item.count-- bot.inventory.updateSlot(slot, item.count > 0 ? item : null!) } break } case 'general.chat': showModal({ reactType: 'chat' }) break case 'general.command': chatInputValueGlobal.value = '/' showModal({ reactType: 'chat' }) break case 'general.selectItem': void selectItem() break case 'general.nextHotbarSlot': cycleHotbarSlot(1) break case 'general.prevHotbarSlot': cycleHotbarSlot(-1) break } } if (command === 'ui.pauseMenu') { showModal({ reactType: 'pause-screen' }) } if (command === 'ui.toggleFullscreen') { void goFullscreen(true) } }) contro.on('release', ({ command }) => { inModalCommand(command, false) onTriggerOrReleased(command, false) }) // hard-coded keybindings export const f3Keybinds = [ { key: 'KeyA', action () { //@ts-expect-error const loadedChunks = Object.entries(worldView.loadedChunks).filter(([, v]) => v).map(([key]) => key.split(',').map(Number)) for (const [x, z] of loadedChunks) { worldView!.unloadChunk({ x, z }) } for (const child of viewer.scene.children) { if (child.name === 'chunk') { // should not happen viewer.scene.remove(child) console.warn('forcefully removed chunk from scene') } } if (localServer) { //@ts-expect-error not sure why it is private... maybe revisit api? localServer.players[0].world.columns = {} } void reloadChunks() }, mobileTitle: 'Reload chunks', }, { key: 'KeyG', action () { options.showChunkBorders = !options.showChunkBorders viewer.world.updateShowChunksBorder(options.showChunkBorders) }, mobileTitle: 'Toggle chunk borders', }, { key: 'KeyY', async action () { // waypoints const widgetNames = widgets.map(widget => widget.name) const widget = await showOptionsModal('Open Widget', widgetNames) if (!widget) return showModal({ reactType: `widget-${widget}` }) }, mobileTitle: 'Open Widget' }, { key: 'KeyT', async action () { // TODO! if (resourcePackState.resourcePackInstalled || loadedGameState.usingServerResourcePack) { showNotification('Reloading textures...') await completeTexturePackInstall('default', 'default', loadedGameState.usingServerResourcePack) } }, mobileTitle: 'Reload Textures' }, { key: 'F4', async action () { switch (bot.game.gameMode) { case 'creative': { bot.chat('/gamemode survival') break } case 'survival': { bot.chat('/gamemode adventure') break } case 'adventure': { bot.chat('/gamemode spectator') break } case 'spectator': { bot.chat('/gamemode creative') break } // No default } }, mobileTitle: 'Cycle Game Mode' } ] const hardcodedPressedKeys = new Set() document.addEventListener('keydown', (e) => { if (!isGameActive(false)) return if (hardcodedPressedKeys.has('F3')) { const keybind = f3Keybinds.find((v) => v.key === e.code) if (keybind) { keybind.action() e.stopPropagation() } return } hardcodedPressedKeys.add(e.code) }, { capture: true, }) document.addEventListener('keyup', (e) => { hardcodedPressedKeys.delete(e.code) }) document.addEventListener('visibilitychange', (e) => { if (document.visibilityState === 'hidden') { hardcodedPressedKeys.clear() } }) // #region creative fly // these controls are more like for gamemode 3 const makeInterval = (fn, interval) => { const intervalId = setInterval(fn, interval) const cleanup = () => { clearInterval(intervalId) cleanup.active = false } cleanup.active = true return cleanup } const isFlying = () => bot.physics.gravity === 0 let endFlyLoop: ReturnType | undefined const currentFlyVector = new Vec3(0, 0, 0) window.currentFlyVector = currentFlyVector // todo cleanup const flyingPressedKeys = { down: false, up: false } const startFlyLoop = () => { if (!isFlying()) return endFlyLoop?.() endFlyLoop = makeInterval(() => { if (!bot) { endFlyLoop?.() return } bot.entity.position.add(currentFlyVector.clone().multiply(new Vec3(0, 0.5, 0))) }, 50) } // todo we will get rid of patching it when refactor controls let originalSetControlState const patchedSetControlState = (action, state) => { if (!isFlying()) { return originalSetControlState(action, state) } const actionPerFlyVector = { jump: new Vec3(0, 1, 0), sneak: new Vec3(0, -1, 0), } const changeVec = actionPerFlyVector[action] if (!changeVec) { return originalSetControlState(action, state) } if (flyingPressedKeys[state === 'jump' ? 'up' : 'down'] === state) return const toAddVec = changeVec.scaled(state ? 1 : -1) for (const coord of ['x', 'y', 'z']) { if (toAddVec[coord] === 0) continue if (currentFlyVector[coord] === toAddVec[coord]) return } currentFlyVector.add(toAddVec) flyingPressedKeys[state === 'jump' ? 'up' : 'down'] = state } const startFlying = (sendAbilities = true) => { bot.entity['creativeFly'] = true if (sendAbilities) { bot._client.write('abilities', { flags: 2, }) } // window.flyingSpeed will be removed bot.physics['airborneAcceleration'] = window.flyingSpeed ?? 0.1 // todo use abilities bot.entity.velocity = new Vec3(0, 0, 0) bot.creative.startFlying() startFlyLoop() } const endFlying = (sendAbilities = true) => { bot.entity['creativeFly'] = false if (bot.physics.gravity !== 0) return if (sendAbilities) { bot._client.write('abilities', { flags: 0, }) } Object.assign(flyingPressedKeys, { up: false, down: false }) currentFlyVector.set(0, 0, 0) bot.physics['airborneAcceleration'] = standardAirborneAcceleration bot.creative.stopFlying() endFlyLoop?.() } let allowFlying = false export const onBotCreate = () => { let wasSpectatorFlying = false bot._client.on('abilities', ({ flags }) => { if (flags & 2) { // flying toggleFly(true, false) } else { toggleFly(false, false) } allowFlying = !!(flags & 4) }) const gamemodeCheck = () => { if (bot.game.gameMode === 'spectator') { toggleFly(true, false) wasSpectatorFlying = true } else if (wasSpectatorFlying) { toggleFly(false, false) wasSpectatorFlying = false } } bot.on('game', () => { gamemodeCheck() }) bot.on('login', () => { gamemodeCheck() }) } const standardAirborneAcceleration = 0.02 const toggleFly = (newState = !isFlying(), sendAbilities?: boolean) => { // if (bot.game.gameMode !== 'creative' && bot.game.gameMode !== 'spectator') return if (!allowFlying) return if (bot.setControlState !== patchedSetControlState) { originalSetControlState = bot.setControlState bot.setControlState = patchedSetControlState } if (newState) { startFlying(sendAbilities) } else { endFlying(sendAbilities) } gameAdditionalState.isFlying = isFlying() } // #endregion const selectItem = async () => { const block = bot.blockAtCursor(5) if (!block) return const itemId = getItemFromBlock(block)?.id if (!itemId) return const Item = require('prismarine-item')(bot.version) const item = new Item(itemId, 1, 0) await bot.creative.setInventorySlot(bot.inventory.hotbarStart + bot.quickBarSlot, item) bot.updateHeldItem() } addEventListener('mousedown', async (e) => { if ((e.target as HTMLElement).matches?.('#VRButton')) return void pointerLock.requestPointerLock() if (!bot) return // wheel click // todo support ctrl+wheel (+nbt) if (e.button === 1) { await selectItem() } }) window.addEventListener('keydown', (e) => { if (e.code !== 'Escape') return if (activeModalStack.length) { hideCurrentModal(undefined, () => { if (!activeModalStack.length) { pointerLock.justHitEscape = true } }) } else if (pointerLock.hasPointerLock) { document.exitPointerLock?.() if (options.autoExitFullscreen) { void document.exitFullscreen() } } else { document.dispatchEvent(new Event('pointerlockchange')) } }) window.addEventListener('keydown', (e) => { if (e.code !== 'F2' || e.repeat || !isGameActive(true)) return e.preventDefault() const canvas = document.getElementById('viewer-canvas') as HTMLCanvasElement if (!canvas) return const link = document.createElement('a') link.href = canvas.toDataURL('image/png') const date = new Date() link.download = `screenshot ${date.toLocaleString().replaceAll('.', '-').replace(',', '')}.png` link.click() }) window.addEventListener('keydown', (e) => { if (e.code !== 'F1' || e.repeat || !isGameActive(true)) return e.preventDefault() miscUiState.showUI = !miscUiState.showUI }) // #region experimental debug things window.addEventListener('keydown', (e) => { if (e.code === 'KeyL' && e.altKey) { console.clear() } }) // #endregion export function updateBinds (commands: any) { contro.inputSchema.commands.custom = Object.fromEntries(Object.entries(commands?.custom ?? {}).map(([key, value]) => { return [key, { keys: [], gamepad: [], type: '', inputs: [] }] })) for (const [group, actions] of Object.entries(commands)) { contro.userConfig![group] = Object.fromEntries(Object.entries(actions).map(([key, value]) => { const newValue = { keys: value?.keys ?? undefined, gamepad: value?.gamepad ?? undefined, } if (group === 'custom') { newValue['type'] = (value).type newValue['inputs'] = (value).inputs } return [key, newValue] })) } }