diff --git a/examples/joystick/dual-stick.html b/examples/joystick/dual-stick.html new file mode 100644 index 000000000..4f270616d --- /dev/null +++ b/examples/joystick/dual-stick.html @@ -0,0 +1,61 @@ + + + + + + + +Joystick Input + + + + + + + diff --git a/examples/joystick/index.html b/examples/joystick/index.html new file mode 100644 index 000000000..a80e31724 --- /dev/null +++ b/examples/joystick/index.html @@ -0,0 +1,63 @@ + + + + + + +Joystick Input + + + + + + diff --git a/src/app/lib/parts/parts/api.ts b/src/app/lib/parts/parts/api.ts index bf1617b30..14c095fd0 100644 --- a/src/app/lib/parts/parts/api.ts +++ b/src/app/lib/parts/parts/api.ts @@ -19,3 +19,4 @@ export * from './speaker/api.js'; export * from './sequencer/api.js'; export * from './touch/api.js'; export * from './voice/api.js'; +export * from './joystick/api.js'; diff --git a/src/app/lib/parts/parts/index.ts b/src/app/lib/parts/parts/index.ts index 2f28f8518..291f24e67 100644 --- a/src/app/lib/parts/parts/index.ts +++ b/src/app/lib/parts/parts/index.ts @@ -19,3 +19,4 @@ export * from './speaker/speaker.js'; export * from './sequencer/sequencer.js'; export * from './touch/touch.js'; export * from './voice/voice.js'; +export * from './joystick/joystick.js'; diff --git a/src/app/lib/parts/parts/joystick/api.ts b/src/app/lib/parts/parts/joystick/api.ts new file mode 100644 index 000000000..d9001147a --- /dev/null +++ b/src/app/lib/parts/parts/joystick/api.ts @@ -0,0 +1,53 @@ +import { IPartAPI } from '../../api.js'; +import { IMetaDefinition } from '../../../meta-api/module.js'; +import { TransformAPI } from '../transform/api.js'; +import { JoystickPart } from './joystick.js'; +import { JoystickInlineDisplay } from './inline.js'; +import icon from './icon.js'; + +export const JoystickAPI : IPartAPI = { + type: JoystickPart.type, + label: 'Joystick', + icon: icon, + inlineDisplay: JoystickInlineDisplay, + color: '#ef5284', + symbols: [ + { + type: 'variable', + name: 'left', + verbose: 'pushed left', + returnType: Boolean, + }, + { + type: 'variable', + name: 'right', + verbose: 'pushed right', + returnType: Boolean, + }, + { + type: 'variable', + name: 'up', + verbose: 'pushed up', + returnType: Boolean, + }, + { + type: 'variable', + name: 'down', + verbose: 'pushed down', + returnType: Boolean, + }, + { + type: 'variable', + name: 'direction', + verbose: 'direction', + returnType: Number + }, + { + type: 'variable', + name: 'force', + verbose: 'force', + returnType: Number, + }, + TransformAPI.find(symbol => symbol.name === 'moveTo') as IMetaDefinition + ] +}; diff --git a/src/app/lib/parts/parts/joystick/enums.ts b/src/app/lib/parts/parts/joystick/enums.ts new file mode 100644 index 000000000..fdace5e25 --- /dev/null +++ b/src/app/lib/parts/parts/joystick/enums.ts @@ -0,0 +1,5 @@ +export enum JoystickAxis { + xy, + x, + y +}; diff --git a/src/app/lib/parts/parts/joystick/icon.ts b/src/app/lib/parts/parts/joystick/icon.ts new file mode 100644 index 000000000..6d4c015c0 --- /dev/null +++ b/src/app/lib/parts/parts/joystick/icon.ts @@ -0,0 +1,8 @@ +import { svg } from '@kano/icons-rendering/index.js'; + +export default svg` + + + + +`; diff --git a/src/app/lib/parts/parts/joystick/inline.ts b/src/app/lib/parts/parts/joystick/inline.ts new file mode 100644 index 000000000..0eebde7b4 --- /dev/null +++ b/src/app/lib/parts/parts/joystick/inline.ts @@ -0,0 +1,53 @@ +import { PartInlineDisplay } from '../../inline-display.js'; +import { JoystickPart } from './joystick.js'; +import { html, render } from 'lit-html/lit-html.js'; +import { memoize } from '../../../util/decorators.js'; +import { JoystickAxis } from './enums.js'; + +function getTemplate(axis : JoystickAxis, callback : () => any) { + return html` + + + `; +} + +export class JoystickInlineDisplay extends PartInlineDisplay { + public domNode : HTMLDivElement = document.createElement('div'); + private part : JoystickPart; + constructor(part : JoystickPart) { + super(part); + this.part = part; + this.domNode.style.display = 'flex'; + this.domNode.style.flexDirection = 'row'; + this.domNode.style.justifyContent = 'flex-end'; + render(getTemplate(part.axis, () => this._updateUnits()), this.domNode); + } + @memoize + getSelect() : HTMLSelectElement { + return this.domNode.querySelector('select') as HTMLSelectElement; + } + _updateUnits() { + const select = this.getSelect(); + const index = select.selectedIndex; + const option = select.options[index]; + this.part.axis = parseInt(option.value, 10) as JoystickAxis; + } + onInject() {} + onDispose() {} +} diff --git a/src/app/lib/parts/parts/joystick/joystick-component.ts b/src/app/lib/parts/parts/joystick/joystick-component.ts new file mode 100644 index 000000000..e1c2f2164 --- /dev/null +++ b/src/app/lib/parts/parts/joystick/joystick-component.ts @@ -0,0 +1,12 @@ +import { property } from '../../decorators.js'; +import { PartComponent } from '../../component.js'; + +export class JoystickComponent extends PartComponent { + + @property({ type: Number, value: 0 }) + public stickX : number = 0; + + @property({ type: Number, value: 0 }) + public stickY : number = 0; + +} diff --git a/src/app/lib/parts/parts/joystick/joystick.ts b/src/app/lib/parts/parts/joystick/joystick.ts new file mode 100644 index 000000000..eb8644470 --- /dev/null +++ b/src/app/lib/parts/parts/joystick/joystick.ts @@ -0,0 +1,365 @@ +import { subscribeDOM, IDisposable } from '@kano/common/index.js'; +import { IPartContext } from '../../part.js'; +import { part, component } from '../../decorators.js'; +import { DOMPart } from '../dom/dom.js'; +import { JoystickComponent} from './joystick-component.js'; +import { JoystickAxis } from './enums.js'; + +// The number of seconds required to transition the stick handle back to the +// rest position when releasing the mouse button or a touch target. For keyboard +// input, this also controls how long it takes to transition the stick to a +// directional position. +const STICK_ANIMATION_DURATION = .1; + +// The size of a joystick (in pixels) +const STICK_SIZE = 75; + +// Dead zone defines how far from centre the handle must move before it is +// considered valid user input. A value of 0.25 indicates that the stick needs to +// move by 25% before input is detected. +const DEAD_ZONE = .25; + +// Try and detect if keyboard input should be ignored for joysticks when +// certain elements have focus, such as s. This prevents the stick +// from moving because of side-effects triggered by the user performing actions +// in other parts of the UI, like moving the cursor to make text selections. +// `composedPath` is used here so events from the shadow dom can be checked. If +// the browser doesn't support composedPath, the joystick will move for all key +// presses. +const getKeyEventFilter = () => { + if ('composedPath' in (window as any).KeyboardEvent.prototype) { + return (e : KeyboardEvent) => { + let originatingElem = e.composedPath()[0]; + return !('form' in originatingElem); + } + } + return () => true; +} + + +const shouldRespondToKeyEvent = getKeyEventFilter(); + +// Define the unique type for the part and extend the parent Part class +@part('joystick') +export class JoystickPart extends DOMPart { + + @component(JoystickComponent) + public core : JoystickComponent; + public axis : JoystickAxis = JoystickAxis.xy; + private _upPressed : boolean = false; + private _downPressed : boolean = false; + private _leftPressed : boolean = false; + private _rightPressed : boolean = false; + private _mouseHandler : IDisposable|null = null; + private _touchHandler : IDisposable|null = null; + private _keyDownHandler : IDisposable|null = null; + private _keyUpHandler : IDisposable|null = null; + + constructor() { + super(); + this.core = this._components.get('core') as JoystickComponent; + this.core.invalidate(); + } + + getElement() : HTMLDivElement { + const el = document.createElement('div'); + el.style.width = `${STICK_SIZE}px`; + el.style.height = `${STICK_SIZE}px`; + el.style.userSelect = 'none'; + el.style.background = `radial-gradient(circle ${STICK_SIZE / 4}px, red 100%, transparent),radial-gradient(circle ${STICK_SIZE / 2}px, #ccc8 80%, transparent 80%)`; + el.style.backgroundRepeat = 'no-repeat'; + el.style.transition = `background-position ${STICK_ANIMATION_DURATION}s`; + return el; + } + + render() { + super.render(); + if (!this.core.invalidated) { + return; + } + if (!this.core.stickX && !this.core.stickY) { + this._el.style.backgroundPosition = ''; + } else { + let direction = Math.atan2(this.core.stickY, this.core.stickX); + let x = Math.cos(direction) * Math.abs(this.core.stickX); + let y = Math.sin(direction) * Math.abs(this.core.stickY); + this._el.style.backgroundPosition = `${Math.floor(x * STICK_SIZE / 4)}px ${Math.floor(y * STICK_SIZE / 4)}px, 50% 50%`; + } + this.core.apply(); + } + + /** + * Sets the joystick values from the current keyboard state. + */ + private updateStickFromKeyState() { + let stickX = 0; + let stickY = 0; + + if (this._upPressed) { + stickY = -1; + } else if (this._downPressed) { + stickY = 1; + } + + if (this._leftPressed) { + stickX = -1; + } else if (this._rightPressed) { + stickX = 1; + } + + this.setStick(stickX, stickY); + } + + /** + * Sets the joystick values based on screen coordinates + * + * @param x - X pixel position of the pointer + * @param y - Y pixel position of the pointer + * @param rect - The bounding rect to clamp coordinates to + */ + private updateStickFromCoordinates(x : number, y : number, rect : ClientRect) { + let localX = Math.max(0, Math.min(rect.width, x - rect.left)); + let localY = Math.max(0, Math.min(rect.height, y - rect.top)); + + // normalise x and y to -1 ... 1 + localX = localX / (rect.width / 2) - 1; + localY = localY / (rect.height / 2) - 1; + + localX = Math.max(-1, Math.min(1, localX * 2)); + localY = Math.max(-1, Math.min(1, localY * 2)); + + this.setStick(localX, localY); + } + + /** + * Disables the animation used when the stick is accepting keyboard input or + * returning back to rest after touch / mouse release. + */ + private disableStickAnimation() { + this._el.style.transition = ''; + } + + /** + * Enables the animation for keyboard input and returning back to rest after + * touch / mouse release. + */ + private enableStickAnimation() { + this._el.style.transition = `background-position ${STICK_ANIMATION_DURATION}s`; + } + + /** + * Finds a specific touch based on it's identifier + * + * @param touches + * @param identifier + */ + private getTouchByIdentifier(touches : TouchList, identifier : number) : Touch | null { + for (let touch of touches) { + if (touch.identifier === identifier) { + return touch; + } + } + return null; + } + + /** + * Set the joystick position. If the joystick is fixed to a specific axis, + * arguments for the relevant axis will be ignored. + * + * @param x - value between -1 and 1 to indicate X input + * @param y - value between -1 and 1 to indicate Y input + */ + setStick(x : number, y : number) { + if (this.axis === JoystickAxis.x || this.axis === JoystickAxis.xy) { + this.core.stickX = x; + } + if (this.axis === JoystickAxis.y || this.axis === JoystickAxis.xy) { + this.core.stickY = y; + } + this.core.invalidate(); + } + + + onInstall(context : IPartContext) { + super.onInstall(context); + + const rootEl = document.body; + const el = this._el; + + this._mouseHandler = subscribeDOM(el, 'mousedown', (e : MouseEvent) => { + if (e.target === el) { + e.preventDefault(); + let rect = el.getBoundingClientRect(); + this.disableStickAnimation(); + + const move = subscribeDOM(rootEl, 'mousemove', (e : MouseEvent) => { + this.updateStickFromCoordinates(e.pageX, e.pageY, rect); + }); + + const end = subscribeDOM(rootEl, 'mouseup', (e : MouseEvent) => { + this.enableStickAnimation(); + this.setStick(0, 0); + move.dispose(); + end.dispose(); + }); + } + }); + + this._touchHandler = subscribeDOM(el, 'touchstart', (e : TouchEvent) => { + if (e.target === el) { + e.preventDefault(); + let touchId = e.changedTouches[0].identifier; + let rect = el.getBoundingClientRect(); + this.disableStickAnimation(); + + const move = subscribeDOM(rootEl, 'touchmove', (e : TouchEvent) => { + let touch = this.getTouchByIdentifier(e.changedTouches, touchId); + if (touch) { + this.updateStickFromCoordinates(touch.pageX, touch.pageY, rect); + } + }); + + const end = subscribeDOM(rootEl, 'touchend', (e : TouchEvent) => { + let touch = this.getTouchByIdentifier(e.changedTouches, touchId); + if (touch) { + this.enableStickAnimation(); + this.setStick(0, 0); + move.dispose(); + end.dispose(); + } + }); + } + }); + + this._keyDownHandler = subscribeDOM(rootEl, 'keydown', (e : KeyboardEvent) => { + if (!shouldRespondToKeyEvent(e)) { + return false; + } + + let needsUpdate = false; + if (!e.repeat) { + if (e.key === 'ArrowRight') { + this._rightPressed = true; + needsUpdate = true; + } + if (e.key === 'ArrowLeft') { + this._leftPressed = true; + needsUpdate = true; + } + if (e.key === 'ArrowUp') { + needsUpdate = true; + this._upPressed = true; + } + if (e.key === 'ArrowDown') { + needsUpdate = true; + this._downPressed = true; + } + if (needsUpdate) { + this.updateStickFromKeyState(); + } + } + }); + + this._keyUpHandler = subscribeDOM(rootEl, 'keyup', (e : KeyboardEvent) => { + if (!shouldRespondToKeyEvent(e)) { + return false; + } + + let needsUpdate = false; + + if (e.key === 'ArrowRight') { + this._rightPressed = false; + needsUpdate = true; + } + if (e.key === 'ArrowLeft') { + this._leftPressed = false; + needsUpdate = true; + } + if (e.key === 'ArrowUp') { + this._upPressed = false; + needsUpdate = true; + } + if (e.key === 'ArrowDown') { + this._downPressed = false; + needsUpdate = true; + } + if (needsUpdate) { + this.updateStickFromKeyState(); + } + }); + } + + /** + * Has the stick been pushed to the left, outside the dead zone? + */ + get left() : boolean { + return this.core.stickX < -DEAD_ZONE; + } + + /** + * Has the stick been pushed to the right, outside the dead zone? + */ + get right() : boolean { + return this.core.stickX > DEAD_ZONE; + } + + /** + * Has the stick been pushed up, outside the dead zone? + */ + get up() : boolean { + return this.core.stickY < -DEAD_ZONE; + } + + /** + * Has the stick been pushed down, outside the dead zone? + */ + get down() : boolean { + return this.core.stickY > DEAD_ZONE; + } + + /** + * The angle of the stick in degrees, with `0` indicating right. + */ + get direction() : number { + return Math.atan2(this.core.stickY, this.core.stickX) * (180 / Math.PI); + } + + /** + * The amount of force applied to the stick + * + * @returns number between 0 and 1 + */ + get force() : number { + let {stickX, stickY} = this.core; + stickX = Math.max(0, (Math.abs(stickX) - DEAD_ZONE) * (1 / DEAD_ZONE)); + stickY = Math.max(0, (Math.abs(stickY) - DEAD_ZONE) * (1 / DEAD_ZONE)); + return Math.sqrt(stickX * stickX + stickY * stickY); + } + + dispose() { + if (this._mouseHandler) { + this._mouseHandler.dispose(); + } + if (this._touchHandler) { + this._touchHandler.dispose(); + } + if (this._keyDownHandler) { + this._keyDownHandler.dispose(); + } + if (this._keyUpHandler) { + this._keyUpHandler.dispose(); + } + super.dispose(); + } + + serialize() { + const data = super.serialize(); + data.axis = this.axis; + return data; + } + + load(data : any) { + super.load(data); + this.axis = data.axis || JoystickAxis.xy; + } +}