pages235/src/controls.ts

535 lines
15 KiB
TypeScript

//@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 { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState } 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 { showOptionsModal } from './react/SelectOption'
import widgets from './react/widgets'
import { getItemFromBlock } from './botUtils'
// todo move this to shared file with component
export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}'))
subscribe(customKeymaps, () => {
localStorage.keymap = JSON.parse(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, 'Left Bumper'],
prevHotbarSlot: [null, 'Right Bumper'],
attackDestroy: [null, 'Right Trigger'],
interactPlace: [null, 'Left Trigger'],
chat: [['KeyT', 'Enter']],
command: ['Slash'],
selectItem: ['KeyH'] // default will be removed
},
ui: {
back: [null/* 'Escape' */, 'B'],
click: [null, 'A'],
},
advanced: {
lockUrl: ['KeyY'],
}
// waila: {
// showLookingBlockRecipe: ['Numpad3'],
// showLookingBlockUsages: ['Numpad4']
// }
} satisfies Record<string, Record<string, SchemaCommandInput>>,
movementKeymap: 'WASD',
movementVector: '2d',
groupedCommands: {
general: {
switchSlot: ['Digits', []]
}
},
}, {
defaultControlOptions: controlOptions,
target: document,
captureEvents () {
return bot && isGameActive(false)
},
storeProvider: {
load: () => customKeymaps,
save () { },
},
gamepadPollingInterval: 10
})
window.controMax = contro
export type Command = CommandEventArgument<typeof contro['_commandsRaw']>['command']
export const setDoPreventDefault = (state: boolean) => {
controlOptions.preventDefault = state
}
const setSprinting = (state: boolean) => {
bot.setControlState('sprint', state)
gameAdditionalState.isSprinting = state
}
contro.on('movementUpdate', ({ vector, gamepadIndex }) => {
miscUiState.usingGamepadInput = gamepadIndex !== undefined
// 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<typeof bot.controlState> = {}
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' () {
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 uiCommand = (command: Command) => {
if (command === 'ui.back') {
hideCurrentModal()
} else if (command === 'ui.click') {
// todo cursor
}
}
const setSneaking = (state: boolean) => {
gameAdditionalState.isSneaking = state
bot.setControlState('sneak', state)
}
const onTriggerOrReleased = (command: Command, pressed: boolean) => {
// always allow release!
if (pressed && !isGameActive(true)) {
uiCommand(command)
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 alwaysHandledCommand = (command: Command) => {
if (command === 'general.inventory') {
if (activeModalStack.at(-1)?.reactType?.startsWith?.('player_win:')) { // todo?
hideCurrentModal()
}
}
}
contro.on('trigger', ({ command }) => {
const willContinue = !isGameActive(true)
alwaysHandledCommand(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')) {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (command) {
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
})
break
case 'general.chat':
showModal({ reactType: 'chat' })
break
case 'general.command':
chatInputValueGlobal.value = '/'
showModal({ reactType: 'chat' })
break
case 'general.selectItem':
void selectItem()
break
}
}
if (command === 'advanced.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 {
const qs = new URLSearchParams()
const { server, version } = localStorage
qs.set('server', server)
if (version) qs.set('version', version)
newQs = String(qs.toString())
}
window.history.replaceState({}, '', `${window.location.pathname}?${newQs}`)
// return
}
})
contro.on('release', ({ command }) => {
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: 'KeyT',
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'
}
]
const hardcodedPressedKeys = new Set<string>()
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<typeof makeInterval> | 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) => {
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) => {
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 = () => {
bot._client.on('abilities', ({ flags }) => {
if (flags & 2) { // flying
toggleFly(true, false)
} else {
toggleFly(false, false)
}
allowFlying = !!(flags & 4)
})
}
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'))
}
})
// #region experimental debug things
window.addEventListener('keydown', (e) => {
if (e.code === 'F11') {
e.preventDefault()
void goFullscreen(true)
}
if (e.code === 'KeyL' && e.altKey) {
console.clear()
}
})
// #endregion