feat: add new experimental touch controls

This commit is contained in:
Vitaly Turovsky 2024-02-19 15:45:47 +03:00
commit 543fc80752
10 changed files with 274 additions and 109 deletions

View file

@ -91,6 +91,7 @@ import { loadInMemorySave } from './react/SingleplayerProvider'
// side effects
import { downloadSoundsIfNeeded } from './soundSystem'
import { ua } from './react/utils'
import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls'
window.debug = debug
window.THREE = THREE
@ -670,6 +671,7 @@ async function connect (connectOptions: {
let screenTouches = 0
let capturedPointer: { id; x; y; sourceX; sourceY; activateCameraMove; time } | undefined
registerListener(document, 'pointerdown', (e) => {
const usingJoystick = options.touchControlsType === 'joystick-buttons'
const clickedEl = e.composedPath()[0]
if (!isGameActive(true) || !miscUiState.currentTouch || clickedEl !== cameraControlEl || e.pointerId === undefined) {
return
@ -679,6 +681,16 @@ async function connect (connectOptions: {
// todo needs fixing!
// window.dispatchEvent(new MouseEvent('mousedown', { button: 1 }))
}
if (usingJoystick) {
if (!joystickPointer.pointer && e.clientX < window.innerWidth / 2) {
joystickPointer.pointer = {
pointerId: e.pointerId,
x: e.clientX,
y: e.clientY
}
return
}
}
if (capturedPointer) {
return
}
@ -692,19 +704,33 @@ async function connect (connectOptions: {
activateCameraMove: false,
time: Date.now()
}
virtualClickTimeout ??= setTimeout(() => {
virtualClickActive = true
document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
}, touchStartBreakingBlockMs)
if (options.touchControlsType !== 'joystick-buttons') {
virtualClickTimeout ??= setTimeout(() => {
virtualClickActive = true
document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
}, touchStartBreakingBlockMs)
}
})
registerListener(document, 'pointermove', (e) => {
if (e.pointerId === undefined || e.pointerId !== capturedPointer?.id) return
if (e.pointerId === undefined) return
const supportsPressure = (e as any).pressure !== undefined && (e as any).pressure !== 0 && (e as any).pressure !== 0.5 && (e as any).pressure !== 1 && (e.pointerType === 'touch' || e.pointerType === 'pen')
if (e.pointerId === joystickPointer.pointer?.pointerId) {
handleMovementStickDelta(e)
if (supportsPressure && (e as any).pressure > 0.5) {
bot.setControlState('sprint', true)
// todo
}
return
}
if (e.pointerId !== capturedPointer?.id) return
window.scrollTo(0, 0)
e.preventDefault()
e.stopPropagation()
const allowedJitter = 1.1
// todo support .pressure (3d touch)
if (supportsPressure) {
bot.setControlState('jump', (e as any).pressure > 0.5)
}
const xDiff = Math.abs(e.pageX - capturedPointer.sourceX) > allowedJitter
const yDiff = Math.abs(e.pageY - capturedPointer.sourceY) > allowedJitter
if (!capturedPointer.activateCameraMove && (xDiff || yDiff)) capturedPointer.activateCameraMove = true
@ -717,18 +743,26 @@ async function connect (connectOptions: {
}, { passive: false })
const pointerUpHandler = (e: PointerEvent) => {
if (e.pointerId === undefined || e.pointerId !== capturedPointer?.id) return
if (e.pointerId === undefined) return
if (e.pointerId === joystickPointer.pointer?.pointerId) {
handleMovementStickDelta()
joystickPointer.pointer = null
return
}
if (e.pointerId !== capturedPointer?.id) return
clearTimeout(virtualClickTimeout)
virtualClickTimeout = undefined
if (virtualClickActive) {
// button 0 is left click
document.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))
virtualClickActive = false
} else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) {
document.dispatchEvent(new MouseEvent('mousedown', { button: 2 }))
worldInteractions.update()
document.dispatchEvent(new MouseEvent('mouseup', { button: 2 }))
if (options.touchControlsType !== 'joystick-buttons') {
if (virtualClickActive) {
// button 0 is left click
document.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))
virtualClickActive = false
} else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) {
document.dispatchEvent(new MouseEvent('mousedown', { button: 2 }))
worldInteractions.update()
document.dispatchEvent(new MouseEvent('mouseup', { button: 2 }))
}
}
capturedPointer = undefined
screenTouches--

View file

@ -188,7 +188,16 @@ export const guiOptionsScheme: {
},
touchButtonsPosition: {
max: 80
}
},
touchControlsType: {
values: [['classic', 'Classic'], ['joystick-buttons', 'New']],
},
},
{
custom () {
const { touchControlsType } = useSnapshot(options)
return <Button label='Setup Touch Buttons' onClick={() => showModal({ reactType: 'touch-buttons-setup' })} inScreen disabled={touchControlsType !== 'joystick-buttons'} />
},
}
],
sound: [

View file

@ -29,7 +29,21 @@ const defaultOptions = {
touchButtonsSize: 40,
touchButtonsOpacity: 80,
touchButtonsPosition: 12,
touchControlsPositions: {} as Record<string, [number, number]>,
touchControlsPositions: {
action: [
90,
70
],
sneak: [
90,
90
],
break: [
70,
70
]
} as Record<string, [number, number]>,
touchControlsType: 'classic' as 'classic' | 'joystick-buttons',
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
/** @unstable */
disableAssets: false,
@ -59,14 +73,19 @@ const defaultOptions = {
// advanced bot options
autoRespawn: false,
mutedSounds: [] as string[]
mutedSounds: [] as string[],
plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>,
}
const migrateOptions = (options) => {
const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
if (options.highPerformanceGpu) {
options.gpuPreference = 'high-performance'
delete options.highPerformanceGpu
}
if (Object.keys(options.touchControlsPositions ?? {}).length === 0) {
options.touchControlsPositions = defaultOptions.touchControlsPositions
}
return options
}

View file

@ -34,7 +34,7 @@ import invspriteJson from './invsprite.json'
import { options } from './optionsStorage'
import { assertDefined } from './utils'
const itemsAtlases: ItemsAtlasesOutputJson = _itemsAtlases
export const itemsAtlases: ItemsAtlasesOutputJson = _itemsAtlases
const loadedImagesCache = new Map<string, HTMLImageElement>()
const cleanLoadedImagesCache = () => {
loadedImagesCache.delete('blocks')

View file

@ -30,7 +30,9 @@ export default ({ status, isError, hideDots = false, lastStatus = '', backAction
<Screen
title={
<>
{status}
<span style={{ userSelect: isError ? 'text' : undefined }}>
{status}
</span>
{isError || hideDots ? '' : loadingDots}
<p className={styles['potential-problem']}>{description}</p>
<p className={styles['last-status']}>{lastStatus ? `Last status: ${lastStatus}` : lastStatus}</p>

View file

@ -1,4 +1,7 @@
import { CSSProperties } from 'react'
// names: https://pixelarticons.com/free/
export default ({ iconName, width, styles = {}, className = undefined }) => {
return <iconify-icon icon={`pixelarticons:${iconName}`} style={{ width, height: width, ...styles }} className={className} />
export default ({ iconName, width = undefined as undefined | number, styles = {} as CSSProperties, className = undefined }) => {
if (width !== undefined) styles = { width, height: width, ...styles }
return <iconify-icon icon={`pixelarticons:${iconName}`} style={styles} className={className} />
}

View file

@ -1,19 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import TouchAreasControls from './TouchAreasControls'
const meta: Meta<typeof TouchAreasControls> = {
component: TouchAreasControls,
args: {
},
}
export default meta
type Story = StoryObj<typeof TouchAreasControls>;
export const Primary: Story = {
args: {
touchActive: true,
setupActive: true,
},
}

View file

@ -1,88 +1,178 @@
import { CSSProperties, useEffect, useRef, useState } from 'react'
import { CSSProperties, PointerEvent, PointerEventHandler, useEffect, useRef, useState } from 'react'
import { proxy, ref, useSnapshot } from 'valtio'
import { contro } from '../controls'
import worldInteractions from '../worldInteractions'
import PixelartIcon from './PixelartIcon'
import Button from './Button'
export type Button = 'action' | 'sneak' | 'break'
export type ButtonName = 'action' | 'sneak' | 'break'
type ButtonsPositions = Record<ButtonName, [number, number]>
interface Props {
touchActive: boolean
setupActive: boolean
buttonsPositions: Record<Button, [number, number]>
buttonsPositions: ButtonsPositions
closeButtonsSetup: (newPositions?: ButtonsPositions) => void
}
export default ({ touchActive, setupActive, buttonsPositions }: Props) => {
if (setupActive) touchActive = true
const getCurrentAppScaling = () => {
// body has css property --guiScale
const guiScale = getComputedStyle(document.body).getPropertyValue('--guiScale')
return parseFloat(guiScale)
}
const [joystickPosition, setJoystickPosition] = useState(null as { x, y, pointerId } | null)
export const joystickPointer = proxy({
pointer: null as { x: number, y: number, pointerId: number } | null,
joystickInner: null as HTMLDivElement | null,
})
useEffect(() => {
if (!touchActive) return
const controller = new AbortController()
const { signal } = controller
addEventListener('pointerdown', (e) => {
if (e.pointerId === joystickPosition?.pointerId) {
const x = e.clientX - joystickPosition.x
const y = e.clientY - joystickPosition.y
const supportsPressure = (e as any).pressure !== undefined && (e as any).pressure !== 0 && (e as any).pressure !== 0.5 && (e as any).pressure !== 1 && (e.pointerType === 'touch' || e.pointerType === 'pen')
if ((e as any).pressure > 0.5) {
// todo
}
return
}
if (e.clientX < window.innerWidth / 2) {
setJoystickPosition({
x: e.clientX,
y: e.clientY,
pointerId: e.pointerId,
})
}
}, {
signal,
})
return () => {
controller.abort()
}
}, [touchActive])
buttonsPositions = {
// 0-100
action: [
90,
70
],
sneak: [
90,
90
],
break: [
70,
70
]
export const handleMovementStickDelta = (e?: { clientX, clientY }) => {
const max = 32
let x = 0
let y = 0
if (e) {
const scale = getCurrentAppScaling()
x = e.clientX - joystickPointer.pointer!.x
y = e.clientY - joystickPointer.pointer!.y
x = Math.min(Math.max(x, -max), max) / scale
y = Math.min(Math.max(y, -max), max) / scale
}
const buttonStyles = (name: Button) => ({
padding: 10,
position: 'fixed',
left: `${buttonsPositions[name][0]}%`,
top: `${buttonsPositions[name][1]}%`,
borderRadius: '50%',
} satisfies CSSProperties)
joystickPointer.joystickInner!.style.transform = `translate(${x}px, ${y}px)`
void contro.emit('movementUpdate', {
vector: {
x: x / max,
y: 0,
z: y / max,
},
})
}
export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup }: Props) => {
if (setupActive) touchActive = true
const joystickOuter = useRef<HTMLDivElement>(null)
const joystickInner = useRef<HTMLDivElement>(null)
const { pointer } = useSnapshot(joystickPointer)
const newButtonPositions = { ...buttonsPositions }
const buttonProps = (name: ButtonName) => {
let active = {
action: false,
sneak: bot.getControlState('sneak'),
break: false
}[name]
const holdDown = {
action () {
document.dispatchEvent(new MouseEvent('mousedown', { button: 2 }))
worldInteractions.update()
document.dispatchEvent(new MouseEvent('mouseup', { button: 2 }))
},
sneak () {
bot.setControlState('sneak', !bot.getControlState('sneak'))
active = bot.getControlState('sneak')
},
break () {
document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
worldInteractions.update()
active = true
}
}
const holdUp = {
action () {
},
sneak () {
},
break () {
document.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))
worldInteractions.update()
active = false
}
}
type PType = PointerEvent<HTMLDivElement>
const pointerup = (e: PType) => {
const elem = e.currentTarget as HTMLElement
console.log(e.type, elem.hasPointerCapture(e.pointerId))
elem.releasePointerCapture(e.pointerId)
if (!setupActive) {
holdUp[name]()
pointerToggledUpdate(e)
}
}
const pointerToggledUpdate = (e) => {
e.currentTarget.style.background = active ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.5)'
}
let setupPointer = null as { x, y } | null
return {
style: {
position: 'fixed',
left: `${buttonsPositions[name][0]}%`,
top: `${buttonsPositions[name][1]}%`,
borderRadius: '50%',
width: '32px',
height: '32px',
background: active ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 0.1s',
} satisfies CSSProperties,
onPointerDown (e: PType) {
const elem = e.currentTarget as HTMLElement
elem.setPointerCapture(e.pointerId)
if (setupActive) {
setupPointer = { x: e.clientX, y: e.clientY }
} else {
holdDown[name]()
pointerToggledUpdate(e)
}
},
onPointerMove (e: PType) {
if (setupPointer) {
const elem = e.currentTarget as HTMLElement
const size = 32
const scale = getCurrentAppScaling()
const xPerc = e.clientX / window.innerWidth * 100 - size / scale
const yPerc = e.clientY / window.innerHeight * 100 - size / scale
elem.style.left = `${xPerc}%`
elem.style.top = `${yPerc}%`
newButtonPositions[name] = [xPerc, yPerc]
}
},
onPointerUp: pointerup,
// onPointerCancel: pointerup,
onLostPointerCapture: pointerup,
}
}
useEffect(() => {
joystickPointer.joystickInner = joystickInner.current && ref(joystickInner.current)
}, [])
if (!touchActive) return null
return <div>
<div
className='movement_joystick_outer'
ref={joystickOuter}
style={{
display: joystickPosition ? 'block' : 'none',
display: pointer ? 'flex' : 'none',
borderRadius: '50%',
width: 50,
height: 50,
border: '2px solid rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(255, 255, div, 0.5)',
position: 'fixed',
left: joystickPosition?.x,
top: joystickPosition?.y,
justifyContent: 'center',
alignItems: 'center',
translate: '-50% -50%',
...pointer ? {
left: `${pointer.x / window.innerWidth * 100}%`,
top: `${pointer.y / window.innerHeight * 100}%`
} : {}
}}>
<div
className='movement_joystick_inner'
@ -92,18 +182,38 @@ export default ({ touchActive, setupActive, buttonsPositions }: Props) => {
height: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
position: 'absolute',
}}
ref={joystickInner}
/>
</div>
<div style={buttonStyles('action')}>
<PixelartIcon width={10} iconName='circle' />
<div {...buttonProps('action')}>
<PixelartIcon iconName='circle' />
</div>
<div style={buttonStyles('sneak')}>
<PixelartIcon width={10} iconName='arrow-down' />
<div {...buttonProps('sneak')}>
<PixelartIcon iconName='arrow-down' />
</div>
<div style={buttonStyles('break')}>
<PixelartIcon width={10} iconName='arrow-down' />
<div {...buttonProps('break')}>
<MineIcon />
</div>
{setupActive && <div style={{
position: 'fixed',
bottom: 0,
display: 'flex',
justifyContent: 'center',
gap: 3
}}>
<Button onClick={() => {
closeButtonsSetup()
}}>Cancel</Button>
<Button onClick={() => {
closeButtonsSetup(newButtonPositions)
}}>Apply</Button>
</div>}
</div>
}
const MineIcon = () => {
return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" width={22} height={22}>
<path d="M 8 0 L 8 2 L 18 2 L 18 0 L 8 0 z M 18 2 L 18 4 L 20 4 L 20 6 L 22 6 L 22 8 L 24 8 L 24 2 L 22 2 L 18 2 z M 24 8 L 24 18 L 26 18 L 26 8 L 24 8 z M 24 18 L 22 18 L 22 20 L 24 20 L 24 18 z M 22 18 L 22 10 L 20 10 L 20 18 L 22 18 z M 20 10 L 20 8 L 18 8 L 18 10 L 20 10 z M 18 10 L 16 10 L 16 12 L 18 12 L 18 10 z M 16 12 L 14 12 L 14 14 L 16 14 L 16 12 z M 14 14 L 12 14 L 12 16 L 14 16 L 14 14 z M 12 16 L 10 16 L 10 18 L 12 18 L 12 16 z M 10 18 L 8 18 L 8 20 L 10 20 L 10 18 z M 8 20 L 6 20 L 6 22 L 8 22 L 8 20 z M 6 22 L 4 22 L 4 24 L 6 24 L 6 22 z M 4 24 L 2 24 L 2 22 L 0 22 L 0 24 L 0 26 L 2 26 L 4 26 L 4 24 z M 2 22 L 4 22 L 4 20 L 2 20 L 2 22 z M 4 20 L 6 20 L 6 18 L 4 18 L 4 20 z M 6 18 L 8 18 L 8 16 L 6 16 L 6 18 z M 8 16 L 10 16 L 10 14 L 8 14 L 8 16 z M 10 14 L 12 14 L 12 12 L 10 12 L 10 14 z M 12 12 L 14 12 L 14 10 L 12 10 L 12 12 z M 14 10 L 16 10 L 16 8 L 14 8 L 14 10 z M 16 8 L 18 8 L 18 6 L 16 6 L 16 8 z M 16 6 L 16 4 L 8 4 L 8 6 L 16 6 z M 8 4 L 8 2 L 6 2 L 6 4 L 8 4 z" stroke='white' />
</svg>
}

View file

@ -1,5 +1,5 @@
import { useSnapshot } from 'valtio'
import { activeModalStack } from '../globalState'
import { activeModalStack, hideModal } from '../globalState'
import { options } from '../optionsStorage'
import TouchAreasControls from './TouchAreasControls'
import { useIsModalActive, useUsingTouch } from './utils'
@ -7,7 +7,13 @@ import { useIsModalActive, useUsingTouch } from './utils'
export default () => {
const usingTouch = useUsingTouch()
const hasModals = useSnapshot(activeModalStack).length !== 0
const setupActive = useIsModalActive('touch-areas-setup')
const setupActive = useIsModalActive('touch-buttons-setup')
const { touchControlsPositions, touchControlsType } = useSnapshot(options)
return <TouchAreasControls touchActive={!!(usingTouch && hasModals)} setupActive={setupActive} buttonsPositions={options.touchControlsPositions} />
return <TouchAreasControls touchActive={!!usingTouch && !hasModals && touchControlsType === 'joystick-buttons'} setupActive={setupActive} buttonsPositions={touchControlsPositions as any} closeButtonsSetup={(newPositions) => {
if (newPositions) {
options.touchControlsPositions = newPositions
}
hideModal()
}} />
}

View file

@ -49,8 +49,9 @@ export default () => {
const usingTouch = useUsingTouch()
const { usingGamepadInput } = useSnapshot(miscUiState)
const modals = useSnapshot(activeModalStack)
const { touchControlsType } = useSnapshot(options)
if (!usingTouch || usingGamepadInput) return null
if (!usingTouch || usingGamepadInput || touchControlsType !== 'classic') return null
return (
<div
style={{ zIndex: modals.length ? 7 : 8 }}