From 4e000f30e5823aeef860dac9ca0c25d50b979ffa Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Tue, 23 Jan 2024 20:39:09 -0600 Subject: [PATCH] Refactor user interactions. Fixes #2 (#9) Refactored all interactions to use pointer events instead of mouse and touch events. Implemented pinch zooming for multi-touch enabled devices and re-implemented pan support for pointer events. The `EventListener` class has been refactored to allow defining an element per event. It also allows defining options on initialization or when adding events. Events specific to Puzzle were moved out of index and into the Puzzle class. Other miscellaneous interactions were better organized into other files. Modifier selection now provides haptic feedback on touch devices. The concept of toggling a modifier has been introduced, which can allow for the toggling of modifier state based on interaction. For example, middle clicking on a modifier with a mouse will toggle it as will triggering a pointer down on a modifier followed by a pointer leave. For now, this behavior is only used to toggle the rotation direction of the rotate modifier, but it could be used for other things in the future. The header/footer drop-shadows now correctly always overlay the canvas area. --- package-lock.json | 4 +- src/components/cache.js | 21 ++- src/components/debug.js | 11 ++ src/components/eventListener.js | 30 ---- src/components/eventListeners.js | 27 +++ src/components/feedback.js | 14 ++ src/components/infoDialog.js | 6 + src/components/interact.js | 177 +++++++++++++++++++ src/components/item.js | 2 +- src/components/items/beam.js | 4 +- src/components/items/portal.js | 2 +- src/components/items/tile.js | 4 +- src/components/modifier.js | 93 +++++----- src/components/modifiers/move.js | 12 +- src/components/modifiers/rotate.js | 21 +-- src/components/modifiers/toggle.js | 4 +- src/components/puzzle.js | 275 +++++++++++++++++------------ src/components/solution.js | 23 ++- src/components/state.js | 2 +- src/components/util.js | 5 - src/index.html | 17 +- src/index.js | 163 ++--------------- src/styles.css | 9 + test/fixtures.js | 2 +- test/puzzles/005.js | 3 +- test/puzzles/008.js | 9 +- 26 files changed, 537 insertions(+), 403 deletions(-) create mode 100644 src/components/debug.js delete mode 100644 src/components/eventListener.js create mode 100644 src/components/eventListeners.js create mode 100644 src/components/feedback.js create mode 100644 src/components/infoDialog.js create mode 100644 src/components/interact.js diff --git a/package-lock.json b/package-lock.json index 97ab3ec..13ebbad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "beams", + "name": "beaming", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "beams", + "name": "beaming", "dependencies": { "@types/chroma-js": "^2.4.3", "chroma-js": "^2.4.2", diff --git a/src/components/cache.js b/src/components/cache.js index e24336c..bdbb853 100644 --- a/src/components/cache.js +++ b/src/components/cache.js @@ -10,7 +10,7 @@ export class Cache { keys.forEach((key) => { this.#cache[key] = new Cache() }) } - add (key, item) { + set (key, item) { if (this.#hasKeys && !this.#keys.includes(key)) { throw new Error(`Invalid key: ${key}`) } @@ -22,7 +22,24 @@ export class Cache { return key ? this.#cache[key] : this.#cache } - remove (key) { + keys (key) { + return Object.keys(this.#get(key)) + } + + length (key) { + return this.keys(key).length + } + + unset (key) { delete this.#cache[key] } + + values (key) { + return Object.values(this.#get(key)) + } + + #get (key) { + const value = this.get(key) + return value instanceof Cache ? value.get() : value + } } diff --git a/src/components/debug.js b/src/components/debug.js new file mode 100644 index 0000000..d0bd204 --- /dev/null +++ b/src/components/debug.js @@ -0,0 +1,11 @@ +import { params } from './util' + +const console = window.console = window.console || { debug: function () {} } +const consoleDebug = console.debug + +export function debug (debug) { + console.debug = debug ? consoleDebug : function () {} +} + +// Silence debug logging by default +debug(params.has('debug') ?? false) diff --git a/src/components/eventListener.js b/src/components/eventListener.js deleted file mode 100644 index 1882495..0000000 --- a/src/components/eventListener.js +++ /dev/null @@ -1,30 +0,0 @@ -export class EventListener { - #element - #listeners = {} - - constructor (context, events) { - Object.entries(events).forEach(([name, config]) => { - // Support [name: handler] - if (typeof config === 'function') { - config = { handler: config } - } - config.handler = config.handler.bind(context) - this.#listeners[name] = config - }) - } - - addEventListeners (element) { - this.#element = element ?? document - Object.entries(this.#listeners).forEach(([name, config]) => - this.#element.addEventListener(name, config.handler, config.options)) - } - - removeEventListeners () { - if (!this.#element) { - return - } - - Object.entries(this.#listeners).forEach(([event, config]) => - this.#element.removeEventListener(event, config.handler)) - } -} diff --git a/src/components/eventListeners.js b/src/components/eventListeners.js new file mode 100644 index 0000000..e3e0b3d --- /dev/null +++ b/src/components/eventListeners.js @@ -0,0 +1,27 @@ +export class EventListeners { + #events = [] + #options = { element: document } + + constructor (options = {}) { + this.#options = Object.assign(this.#options, options) + } + + add (events, options = {}) { + this.#events = this.#events.concat(events.map((event) => { + event = Object.assign({}, this.#options, options, event) + if (!event.type) { + throw new Error('Event type is required') + } + if (event.context) { + event.handler = event.handler.bind(event.context) + } + event.element.addEventListener(event.type, event.handler, event.options) + return event + })) + } + + remove () { + this.#events.forEach((event) => event.element.removeEventListener(event.type, event.handler)) + this.#events = [] + } +} diff --git a/src/components/feedback.js b/src/components/feedback.js new file mode 100644 index 0000000..3975834 --- /dev/null +++ b/src/components/feedback.js @@ -0,0 +1,14 @@ +import { Puzzle } from './puzzle' + +const container = document.getElementById('feedback-container') +const help = document.getElementById('help') + +document.getElementById('feedback').addEventListener('click', () => { + help.setAttribute('open', 'true') + container.scrollIntoView(true) +}) + +const doorbellOptions = window.doorbellOptions +document.addEventListener(Puzzle.Events.Updated, (event) => { + doorbellOptions.properties.puzzleId = event.detail.state.getId() +}) diff --git a/src/components/infoDialog.js b/src/components/infoDialog.js new file mode 100644 index 0000000..21144e5 --- /dev/null +++ b/src/components/infoDialog.js @@ -0,0 +1,6 @@ +const dialog = document.getElementById('dialog') +document.getElementById('info').addEventListener('click', () => { + if (!dialog.open) { + dialog.showModal() + } +}) diff --git a/src/components/interact.js b/src/components/interact.js new file mode 100644 index 0000000..f8ca433 --- /dev/null +++ b/src/components/interact.js @@ -0,0 +1,177 @@ +import paper, { Point } from 'paper' +import { Cache } from './cache' +import { EventListeners } from './eventListeners' + +export class Interact { + #bounds + #cache = new Cache(Object.values(Interact.CacheKeys)) + #element + #eventListener = new EventListeners({ context: this }) + #offset + + constructor (element) { + this.#bounds = element.getBoundingClientRect() + this.#element = element + this.#offset = new Point(this.#bounds.left, this.#bounds.top) + this.#eventListener.add([ + { type: 'pointercancel', handler: this.onPointerUp }, + { type: 'pointerdown', handler: this.onPointerDown }, + { type: 'pointerleave', handler: this.onPointerUp }, + { type: 'pointermove', handler: this.onPointerMove }, + { type: 'pointerout', handler: this.onPointerUp }, + { type: 'pointerup', handler: this.onPointerUp }, + { type: 'wheel', handler: this.onMouseWheel, options: { passive: false } } + ], { element }) + } + + onMouseWheel (event) { + event.preventDefault() + this.#zoom(new Point(event.offsetX, event.offsetY), event.deltaY, 1.05) + } + + onPan (event) { + const point = this.#getPoint(event) + const pan = this.#getGesture(Interact.GestureKeys.Pan) + + if (!pan) { + this.#setGesture(Interact.GestureKeys.Pan, { from: point }) + return + } + + const center = pan.from.subtract(point).add(paper.view.center) + + // Allow a little wiggle room to prevent panning on tap + if (paper.view.center.subtract(center).length > 1) { + if (!document.body.classList.contains('grab')) { + document.body.classList.add('grab') + } + + // Center on the cursor + paper.view.center = center + } + } + + onPinch (events) { + const pointer0 = events[0] + const pointer1 = events[1] + + const delta = Math.abs(pointer0.clientX - pointer1.clientX) + const pinch = this.#getGesture(Interact.GestureKeys.Pinch) + + if (!pinch) { + const point0 = new Point(pointer0.clientX, pointer0.clientY) + const point1 = new Point(pointer1.clientX, pointer1.clientY) + const vector = point1.subtract(point0).divide(2) + const center = point1.subtract(vector).subtract(this.#offset) + this.#setGesture(Interact.GestureKeys.Pinch, { center, delta }) + return + } + + if (delta > 1) { + this.#zoom(pinch.center, pinch.delta - delta, 1.01) + } + + pinch.delta = delta + } + + onPointerDown (event) { + this.#cache.get(Interact.CacheKeys.Down).set(event.pointerId, event) + } + + onPointerMove (event) { + const down = this.#cache.get(Interact.CacheKeys.Down).get(event.pointerId) + if (!down) { + // Ignore events until there is a pointer down event + return + } + + // For some reason pointermove fires on mobile even if there was no movement + const diff = this.#getPoint(event).subtract(this.#getPoint(down)).length + if (diff > 1) { + this.#cache.get(Interact.CacheKeys.Move).set(event.pointerId, event) + + const events = this.#cache.get(Interact.CacheKeys.Move).values() + if (events.length === 2) { + this.onPinch(events) + } else { + this.onPan(event) + } + } + } + + onPointerUp (event) { + const down = this.#cache.get(Interact.CacheKeys.Down).get(event.pointerId) + if (!down) { + return + } + + if ( + this.#cache.length(Interact.CacheKeys.Down) === 1 && + !this.#cache.get(Interact.CacheKeys.Move).get(event.pointerId) + ) { + this.onTap(down) + } + + document.body.classList.remove('grab') + + this.#cache.get(Interact.CacheKeys.Down).unset(event.pointerId) + this.#cache.get(Interact.CacheKeys.Move).unset(event.pointerId) + this.#cache.get(Interact.CacheKeys.Gesture).unset(Interact.GestureKeys.Pan) + + if (this.#cache.length(Interact.CacheKeys.Move) < 2) { + this.#cache.get(Interact.CacheKeys.Gesture).unset(Interact.GestureKeys.Pinch) + } + } + + onTap (event) { + const point = this.#getPoint(event) + this.#element.dispatchEvent(new CustomEvent(Interact.GestureKeys.Tap, { detail: { event, point } })) + } + + #getGesture (key) { + return this.#cache.get(Interact.CacheKeys.Gesture).get(key) + } + + #getPoint (event) { + return paper.view.viewToProject(new Point(event.clientX, event.clientY).subtract(this.#offset)) + } + + #setGesture (key, value) { + this.#cache.get(Interact.CacheKeys.Gesture).set(key, value) + } + + #zoom (point, delta, factor) { + const zoom = delta < 0 ? paper.view.zoom * factor : paper.view.zoom / factor + + // Don't allow zooming too far in or out + if (zoom > 2 || zoom < 0.5) { + return + } + + // Convert the touch point from the view coordinate space to the project coordinate space + const touchPoint = paper.view.viewToProject(point) + const touchOffset = touchPoint.subtract(paper.view.center) + + // Adjust center towards cursor location + const zoomOffset = touchPoint + .subtract(touchOffset.multiply(paper.view.zoom / zoom)) + .subtract(paper.view.center) + + paper.view.zoom = zoom + paper.view.center = paper.view.center.add(zoomOffset) + } + + static CacheKeys = Object.freeze({ + Down: 'down', + Move: 'move', + Gesture: 'gesture' + }) + + static GestureKeys = Object.freeze({ + Pan: 'pan', + Pinch: 'pinch', + Tap: 'tap' + }) + + static vibratePattern = 25 +} diff --git a/src/components/item.js b/src/components/item.js index a944bf5..5dc4f87 100644 --- a/src/components/item.js +++ b/src/components/item.js @@ -53,7 +53,7 @@ export class Item extends Stateful { return this.group.parent } - onClick () {} + onTap () {} onCollision ({ collisionStep }) { return collisionStep diff --git a/src/components/items/beam.js b/src/components/items/beam.js index 3e185c2..1847337 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -325,10 +325,10 @@ export class Beam extends Item { this.addStep(step.copy({ colors: mergeWith.colors, onAdd: () => { - this.#cache.get(Beam.CacheKeys.MergeWith).add(beam.id, mergeWith) + this.#cache.get(Beam.CacheKeys.MergeWith).set(beam.id, mergeWith) }, onRemove: () => { - this.#cache.get(Beam.CacheKeys.MergeWith).remove(beam.id) + this.#cache.get(Beam.CacheKeys.MergeWith).unset(beam.id) }, state: step.state.copy(new StepState.MergeWith(mergeWith)) })) diff --git a/src/components/items/portal.js b/src/components/items/portal.js index a607548..25f624a 100644 --- a/src/components/items/portal.js +++ b/src/components/items/portal.js @@ -112,7 +112,7 @@ export class Portal extends movable(rotatable(Item)) { }, { beam, - onClick: (puzzle, tile) => { + onTap: (puzzle, tile) => { const destination = destinations.find((portal) => portal.parent === tile) if (destination) { beam.addStep(this.#step(destination, nextStep, portalState)) diff --git a/src/components/items/tile.js b/src/components/items/tile.js index 2846e31..f00b3f2 100644 --- a/src/components/items/tile.js +++ b/src/components/items/tile.js @@ -66,9 +66,9 @@ export class Tile extends Item { return state } - onClick (event) { + onTap (event) { console.debug(this.coordinates.offset.toString(), this) - this.items.forEach((item) => item.onClick(event)) + this.items.forEach((item) => item.onTap(event)) } onDeselected (selectedTile) { diff --git a/src/components/modifier.js b/src/components/modifier.js index 52dee7f..85acac0 100644 --- a/src/components/modifier.js +++ b/src/components/modifier.js @@ -1,16 +1,19 @@ import { capitalize, emitEvent } from './util' import { Puzzle } from './puzzle' import { Stateful } from './stateful' -import { EventListener } from './eventListener' +import { EventListeners } from './eventListeners' +import { Interact } from './interact' const modifiersImmutable = document.getElementById('modifiers-immutable') const modifiersMutable = document.getElementById('modifiers-mutable') +const navigator = window.navigator let uniqueId = 0 export class Modifier extends Stateful { #container - #eventListener + #down = false + #eventListener = new EventListeners({ context: this }) #mask #selectionTime = 500 #timeoutId @@ -31,16 +34,6 @@ export class Modifier extends Stateful { this.tile = tile this.type = state.type - - this.#eventListener = new EventListener(this, { - click: this.#onClick, - deselected: this.onDeselected, - mousedown: this.onMouseDown, - mouseleave: this.onMouseLeave, - mouseup: this.onMouseUp, - touchstart: { handler: this.onTouchStart, options: { passive: false } }, - touchend: this.onTouchEnd - }) } /** @@ -63,7 +56,12 @@ export class Modifier extends Stateful { this.update() - this.#eventListener.addEventListeners(li) + this.#eventListener.add([ + { type: 'deselected', handler: this.onDeselected }, + { type: 'pointerdown', handler: this.onPointerDown }, + { type: 'pointerleave', handler: this.onPointerUp }, + { type: 'pointerup', handler: this.onPointerUp } + ], { element: li }) this.immutable ? modifiersImmutable.append(li) : modifiersMutable.append(li) } @@ -78,7 +76,7 @@ export class Modifier extends Stateful { Modifier.deselect() - this.#eventListener.removeEventListeners() + this.#eventListener.remove() this.#container.remove() this.selected = false @@ -105,54 +103,65 @@ export class Modifier extends Stateful { return tile.modifiers.some((modifier) => modifier.type === Modifier.Types.immutable) } - onClick () { - this.selected = false - } - onDeselected () { this.update({ selected: false }) this.tile.afterModify() this.dispatchEvent(Modifier.Events.Deselected) } - onMouseDown () { - if ( - !this.#mask && - !this.tile.modifiers.some((modifier) => [Modifier.Types.immutable, Modifier.Types.lock].includes(modifier.type)) - ) { - this.#timeoutId = setTimeout(this.onSelected.bind(this), this.#selectionTime) + onPointerDown (event) { + if (event.button !== 0) { + // Support toggle on non-primary pointer button + this.onToggle(event) + } else { + this.#down = true + if ( + !this.#mask && + !this.tile.modifiers.some((modifier) => [Modifier.Types.immutable, Modifier.Types.lock].includes(modifier.type)) + ) { + this.#timeoutId = setTimeout(this.onSelected.bind(this), this.#selectionTime) + } } } - onMouseLeave () { + onPointerUp (event) { clearTimeout(this.#timeoutId) - } - onMouseUp () { - clearTimeout(this.#timeoutId) + if (this.#down && !this.disabled && !this.selected) { + switch (event.type) { + case 'pointerleave': { + // Support swiping up on pointer device + this.onToggle(event) + break + } + case 'pointerup': { + this.onTap(event) + break + } + } + } + + this.#down = false } onSelected () { Modifier.deselect() + navigator.vibrate(Interact.vibratePattern) + this.update({ selected: true }) this.tile.beforeModify() - const mask = this.#mask = new Puzzle.Mask(this.#moveFilter.bind(this), { onClick: this.#maskOnClick.bind(this) }) + const mask = this.#mask = new Puzzle.Mask(this.#moveFilter.bind(this), { onTap: this.#maskOnTap.bind(this) }) this.dispatchEvent(Puzzle.Events.Mask, { mask }) } - onTouchEnd (event) { - // Prevent any mouse events from firing - event.preventDefault() - this.onMouseUp(event) - this.#onClick(event) + onTap () { + this.selected = false } - onTouchStart (event) { - // Prevent any mouse events from firing - event.preventDefault() - this.onMouseDown(event) + onToggle () { + navigator.vibrate(Interact.vibratePattern) } remove () { @@ -178,7 +187,7 @@ export class Modifier extends Stateful { this.element.title = this.title } - #maskOnClick (puzzle, tile) { + #maskOnTap (puzzle, tile) { if (tile && tile !== this.tile) { const fromTile = this.tile @@ -203,12 +212,6 @@ export class Modifier extends Stateful { return !tile.equals(this.tile) && this.moveFilter(tile) } - #onClick (event) { - if (!this.disabled && !this.selected) { - return this.onClick(event) - } - } - static deselect () { const selectedModifier = document.querySelector('.modifiers .selected') if (selectedModifier) { diff --git a/src/components/modifiers/move.js b/src/components/modifiers/move.js index b8d9315..ef60355 100644 --- a/src/components/modifiers/move.js +++ b/src/components/modifiers/move.js @@ -9,12 +9,8 @@ export class Move extends Modifier { name = 'drag_pan' title = 'Move' - attach () { - super.attach() - } - - onClick (event) { - super.onClick(event) + onTap (event) { + super.onTap(event) const items = this.tile.items.filter(Move.movable) if (this.#mask || !items.length) { @@ -24,7 +20,7 @@ export class Move extends Modifier { this.tile.beforeModify() const mask = new Puzzle.Mask(this.tileFilter.bind(this), { - onClick: this.#maskOnClick.bind(this), + onTap: this.#maskOnTap.bind(this), onUnmask: () => this.tile.afterModify() }) @@ -53,7 +49,7 @@ export class Move extends Modifier { (tile.items.filter((item) => item.type !== Item.Types.beam).length > 0 && !(tile === this.tile)) } - #maskOnClick (puzzle, tile) { + #maskOnTap (puzzle, tile) { if (tile) { const data = this.moveItems(tile) diff --git a/src/components/modifiers/rotate.js b/src/components/modifiers/rotate.js index c24f90a..49ac63e 100644 --- a/src/components/modifiers/rotate.js +++ b/src/components/modifiers/rotate.js @@ -1,5 +1,5 @@ import { Modifier } from '../modifier' -import { addDirection, coalesce, MouseButton } from '../util' +import { addDirection, coalesce } from '../util' export class Rotate extends Modifier { clockwise @@ -17,8 +17,8 @@ export class Rotate extends Modifier { return super.moveFilter(tile) || !tile.items.some((item) => item.rotatable) } - onClick (event) { - super.onClick(event) + onTap (event) { + super.onTap(event) const items = this.tile.items.filter((item) => item.rotatable) items.forEach((item) => item.rotate(this.clockwise)) @@ -26,15 +26,12 @@ export class Rotate extends Modifier { this.dispatchEvent(Modifier.Events.Invoked, { items }) } - onMouseDown (event) { - // Change rotation direction if user right-clicks on the modifier - if (event.button === MouseButton.Right) { - this.clockwise = !this.clockwise - this.updateState((state) => { state.clockwise = this.clockwise }) - this.update({ name: Rotate.Names[this.clockwise ? 'right' : 'left'] }) - } else { - super.onMouseDown(event) - } + onToggle () { + super.onToggle() + + this.clockwise = !this.clockwise + this.updateState((state) => { state.clockwise = this.clockwise }) + this.update({ name: Rotate.Names[this.clockwise ? 'right' : 'left'] }) } static Names = Object.freeze({ left: 'rotate_left', right: 'rotate_right ' }) diff --git a/src/components/modifiers/toggle.js b/src/components/modifiers/toggle.js index 6bcf19f..9d900de 100644 --- a/src/components/modifiers/toggle.js +++ b/src/components/modifiers/toggle.js @@ -24,8 +24,8 @@ export class Toggle extends Modifier { return super.moveFilter(tile) || !tile.items.some((item) => item.toggleable) } - onClick (event) { - super.onClick(event) + onTap (event) { + super.onTap(event) this.on = !this.on diff --git a/src/components/puzzle.js b/src/components/puzzle.js index d8f7b80..2263759 100644 --- a/src/components/puzzle.js +++ b/src/components/puzzle.js @@ -1,7 +1,7 @@ import { Layout } from './layout' import chroma from 'chroma-js' -import paper, { Layer, Path, Tool } from 'paper' -import { emitEvent, fuzzyEquals } from './util' +import paper, { Layer, Path, Size } from 'paper' +import { addClass, debounce, emitEvent, fuzzyEquals, removeClass } from './util' import { Item } from './item' import { Mask } from './items/mask' import { Modifier } from './modifier' @@ -12,15 +12,21 @@ import { OffsetCoordinates } from './coordinates/offset' import { State } from './state' import { Puzzles } from '../puzzles' import { StepState } from './step' -import { EventListener } from './eventListener' +import { EventListeners } from './eventListeners' import { Solution } from './solution' +import { Interact } from './interact' const elements = Object.freeze({ - beams: document.getElementById('beams'), - connections: document.getElementById('connections'), - connectionsCompleted: document.getElementById('connections-completed'), - connectionsRequired: document.getElementById('connections-required'), - message: document.getElementById('message') + main: document.getElementById('main'), + message: document.getElementById('message'), + next: document.getElementById('next'), + previous: document.getElementById('previous'), + puzzle: document.getElementById('puzzle'), + puzzleId: document.getElementById('puzzle-id'), + redo: document.getElementById('redo'), + reset: document.getElementById('reset'), + undo: document.getElementById('undo'), + title: document.querySelector('title') }) // There are various spots below that utilize setTimeout in order to process events in order and to prevent @@ -37,36 +43,47 @@ export class Puzzle { #beams #collisions = {} - #eventListener - #isDragging = false + #eventListeners = new EventListeners({ context: this }) + #interact #isUpdatingBeams = false #mask #solution #state #termini #tiles = [] - #tool - constructor (canvas) { + constructor () { // Don't automatically insert items into the scene graph, they must be explicitly inserted paper.settings.insertItems = false - paper.setup(canvas) + // noinspection JSCheckFunctionSignatures + paper.setup(elements.puzzle) + + this.#resize() this.layers.mask = new Layer() this.layers.collisions = new Layer() this.layers.debug = new Layer() - this.#eventListener = new EventListener(this, { - keyup: this.#onKeyup, - [Beam.Events.Update]: this.#onBeamUpdate, - [Modifier.Events.Invoked]: this.#onModifierInvoked, - [Puzzle.Events.Mask]: this.#onMask, - [Stateful.Events.Update]: this.#onStateUpdate - }) + this.#eventListeners.add([ + { type: Beam.Events.Update, handler: this.#onBeamUpdate }, + { type: 'change', element: elements.puzzleId, handler: this.#onSelect }, + { type: 'click', element: elements.next, handler: this.#next }, + { type: 'click', element: elements.previous, handler: this.#previous }, + { type: 'click', element: elements.redo, handler: this.#redo }, + { type: 'click', element: elements.reset, handler: this.#reset }, + { type: 'click', element: elements.undo, handler: this.#undo }, + { type: 'keyup', handler: this.#onKeyup }, + { type: Modifier.Events.Invoked, handler: this.#onModifierInvoked }, + { type: Puzzle.Events.Mask, handler: this.#onMask }, + { type: 'resize', element: window, handler: debounce(this.#resize) }, + { type: Stateful.Events.Update, handler: this.#onStateUpdate }, + { type: 'tap', element: elements.puzzle, handler: this.#onTap } + ]) + + this.#interact = new Interact(elements.puzzle) + this.#updateDropdown() - this.#tool = new Tool() - this.#tool.onMouseDrag = (event) => this.#onMouseDrag(event) - this.#tool.onMouseUp = (event) => this.#onMouseUp(event) + this.select() } centerOnTile (offset) { @@ -74,6 +91,10 @@ export class Puzzle { paper.view.center = tile.center } + clearDebugPoints () { + this.layers.debug.clear() + } + drawDebugPoint (point, style = {}) { const circle = new Path.Circle(Object.assign({ radius: 3, @@ -124,44 +145,18 @@ export class Puzzle { } } - next () { - const id = Puzzles.visible.nextId(this.#state.getId()) - if (id) { - this.select(id) - } - } - - previous () { - const id = Puzzles.visible.previousId(this.#state.getId()) - if (id) { - this.select(id) + select (id) { + if (id !== undefined && id === this.#state?.getId()) { + // This ID is already selected + return } - } - redo () { - this.#state.redo() - this.#reload() - } - - reset () { - this.#state.reset() - this.#reload() - } - - select (id) { try { this.#state = State.resolve(id) } catch (e) { this.#onError(e, 'Could not load puzzle.') } - emitEvent(Puzzle.Events.Updated, { state: this.#state }) - - this.#reload() - } - - undo () { - this.#state.undo() this.#reload() } @@ -202,6 +197,7 @@ export class Puzzle { updateState () { this.#state.update(Object.assign(this.#state.getCurrent(), { layout: this.layout.getState() })) + this.#updateActions() emitEvent(Puzzle.Events.Updated, { state: this.#state }) } @@ -216,6 +212,13 @@ export class Puzzle { ].forEach((layer) => paper.project.addLayer(layer)) } + #next () { + const id = Puzzles.visible.nextId(this.#state.getId()) + if (id) { + this.select(id) + } + } + #onBeamUpdate (event) { const beam = event.detail.beam const state = event.detail.state @@ -247,35 +250,6 @@ export class Puzzle { setTimeout(() => this.update(), 0) } - #onClick (event) { - let tile - - if (this.#isDragging || this.solved || this.error) { - return - } - - const result = paper.project.hitTest(event.point) - - switch (result?.item.data.type) { - case Item.Types.mask: - return - case Item.Types.tile: - tile = this.layout.getTileByAxial(result.item.data.coordinates.axial) - break - } - - // There is an active mask - if (this.#mask) { - this.#mask.onClick(this, tile) - } else { - const previouslySelectedTile = this.updateSelectedTile(tile) - - if (tile && tile === previouslySelectedTile) { - tile.onClick(event) - } - } - } - #onError (error, message, cause) { this.error = true @@ -322,31 +296,8 @@ export class Puzzle { setTimeout(() => this.update(), 0) } - #onMouseDrag (event) { - const center = event.downPoint.subtract(event.point).add(paper.view.center) - - // Allow a little wiggle room - if (paper.view.center.subtract(center).length > 1) { - if (!this.#isDragging) { - document.body.classList.add('grab') - } - - // Note: MouseDrag is always called on mobile even when tapping, so only consider it actually dragging if - // the cursor has moved the center - this.#isDragging = true - - // Center on the cursor - paper.view.center = center - } - } - - #onMouseUp (event) { - if (!this.#isDragging) { - this.#onClick(event) - } - - this.#isDragging = false - document.body.classList.remove('grab') + #onSelect (event) { + this.select(event.target.value) } #onSolved () { @@ -374,6 +325,47 @@ export class Puzzle { this.updateState() } + #onTap (event) { + let tile + + if (this.solved || this.error) { + return + } + + const result = paper.project.hitTest(event.detail.point) + + switch (result?.item.data.type) { + case Item.Types.mask: + return + case Item.Types.tile: + tile = this.layout.getTileByAxial(result.item.data.coordinates.axial) + break + } + + // There is an active mask + if (this.#mask) { + this.#mask.onTap(this, tile) + } else { + const previouslySelectedTile = this.updateSelectedTile(tile) + + if (tile && tile === previouslySelectedTile) { + tile.onTap(event) + } + } + } + + #previous () { + const id = Puzzles.visible.previousId(this.#state.getId()) + if (id) { + this.select(id) + } + } + + #redo () { + this.#state.redo() + this.#reload() + } + #reload () { this.error = false @@ -391,6 +383,18 @@ export class Puzzle { paper.project.clear() } + #reset () { + this.#state.reset() + this.#reload() + } + + #resize () { + const { width, height } = elements.main.getBoundingClientRect() + elements.puzzle.style.height = height + 'px' + elements.puzzle.style.width = width + 'px' + paper.view.viewSize = new Size(width, height) + } + #setup () { // Reset the item IDs, so they are unique per-puzzle Item.uniqueId = 0 @@ -405,7 +409,6 @@ export class Puzzle { this.#termini = this.layout.items.filter((item) => item.type === Item.Types.terminus) this.#beams = this.#termini.flatMap((terminus) => terminus.beams) - this.#eventListener.addEventListeners() this.#addLayers() document.body.classList.add(Puzzle.Events.Loaded) @@ -417,12 +420,12 @@ export class Puzzle { this.updateSelectedTile(selectedTile) this.update() + this.#updateActions() } #teardown () { document.body.classList.remove(...Object.values(Puzzle.Events)) - this.#eventListener.removeEventListeners() this.#removeLayers() this.#tiles.forEach((tile) => tile.teardown()) @@ -435,12 +438,62 @@ export class Puzzle { this.selectedTile = undefined this.#beams = [] this.#collisions = {} - this.#isDragging = false this.#isUpdatingBeams = false this.#mask = undefined this.#termini = [] } + #undo () { + this.#state.undo() + this.#reload() + } + + #updateActions () { + const id = this.#state.getId() + const title = this.#state.getTitle() + + // Update browser title + elements.title.textContent = `Beaming: Puzzle ${title}` + + removeClass('disabled', ...Array.from(document.querySelectorAll('#actions li'))) + + const disable = [] + + if (!this.#state.canUndo()) { + disable.push(elements.undo) + } + + if (!this.#state.canRedo()) { + disable.push(elements.redo) + } + + if (!Puzzles.visible.has(id)) { + // Custom puzzle + elements.puzzleId.value = '' + disable.push(elements.previous, elements.next) + } else { + elements.puzzleId.value = id + + if (id === Puzzles.visible.firstId) { + disable.push(elements.previous) + } else if (id === Puzzles.visible.lastId) { + disable.push(elements.next) + } + } + + addClass('disabled', ...disable) + } + + #updateDropdown () { + elements.puzzleId.replaceChildren() + for (const id of Puzzles.visible.ids) { + const option = document.createElement('option') + option.value = id + option.innerText = Puzzles.titles[id] + elements.puzzleId.append(option) + } + } + #updateBeams () { const beams = this.#beams.filter((beam) => beam.isPending()) @@ -555,7 +608,7 @@ export class Puzzle { this.configuration = configuration this.filter = filter - this.onClick = configuration.onClick + this.onTap = configuration.onTap this.onUnmask = configuration.onUnmask } } diff --git a/src/components/solution.js b/src/components/solution.js index 747b218..a0d0ac7 100644 --- a/src/components/solution.js +++ b/src/components/solution.js @@ -1,6 +1,6 @@ import { capitalize, getIconElement, getTextElement } from './util' import { Terminus } from './items/terminus' -import { EventListener } from './eventListener' +import { EventListeners } from './eventListeners' import { Puzzle } from './puzzle' export class Solution { @@ -62,7 +62,7 @@ class SolutionCondition { class Connections extends SolutionCondition { #completed - #eventListener + #eventListeners = new EventListeners({ context: this }) #connections = [] constructor (state) { @@ -83,12 +83,10 @@ class Connections extends SolutionCondition { this.#completed = completed - this.#eventListener = new EventListener(this, { - [Terminus.Events.Connection]: this.update, - [Terminus.Events.Disconnection]: this.update - }) - - this.#eventListener.addEventListeners() + this.#eventListeners.add([ + { type: Terminus.Events.Connection, handler: this.update }, + { type: Terminus.Events.Disconnection, handler: this.update } + ]) } isMet () { @@ -96,7 +94,7 @@ class Connections extends SolutionCondition { } teardown () { - this.#eventListener.removeEventListeners() + this.#eventListeners.remove() super.teardown() } @@ -120,7 +118,7 @@ class Connections extends SolutionCondition { class Moves extends SolutionCondition { #completed - #eventListener + #eventListeners = new EventListeners({ context: this }) #moves = 0 constructor (state) { @@ -146,8 +144,7 @@ class Moves extends SolutionCondition { super(state, elements) this.#completed = completed - this.#eventListener = new EventListener(this, { [Puzzle.Events.Updated]: this.update }) - this.#eventListener.addEventListeners() + this.#eventListeners.add([{ type: Puzzle.Events.Updated, handler: this.update }]) } isMet () { @@ -163,7 +160,7 @@ class Moves extends SolutionCondition { } teardown () { - this.#eventListener.removeEventListeners() + this.#eventListeners.remove() super.teardown() } diff --git a/src/components/state.js b/src/components/state.js index bca95fe..2e52a5d 100644 --- a/src/components/state.js +++ b/src/components/state.js @@ -241,7 +241,7 @@ export class State { const originalVersion = state.#original.version if (cachedVersion !== originalVersion) { - console.log( + console.debug( `Invalidating cache for ID ${id} due to version mismatch. ` + `Puzzle: ${originalVersion}, Cache: ${cachedVersion}` ) diff --git a/src/components/util.js b/src/components/util.js index 6f0c6e8..e7d6f1a 100644 --- a/src/components/util.js +++ b/src/components/util.js @@ -10,11 +10,6 @@ export const url = new URL(location) // noinspection JSCheckFunctionSignatures export const jsonDiffPatch = jsonDiffPatchFactory.create({ objectHash: deepEqual }) -export const MouseButton = Object.freeze({ - Left: 0, - Right: 2 -}) - export function addClass (className, ...elements) { elements.forEach((element) => element.classList.add(className)) } diff --git a/src/index.html b/src/index.html index 6573f5f..5d12955 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - + @@ -59,13 +59,16 @@

Beaming

@@ -176,7 +179,7 @@

Thanks!

diff --git a/src/index.js b/src/index.js index 97747b3..04a5d1e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,165 +1,22 @@ -import paper, { Point, Size } from 'paper' +import './components/feedback' +import './components/infoDialog' +import { debug } from './components/debug' +import { Point } from 'paper' import { Puzzle } from './components/puzzle' -import { addClass, debounce, params, removeClass } from './components/util' -import { Puzzles } from './puzzles' import { OffsetCoordinates } from './components/coordinates/offset' -const beaming = window.beaming = {} -const console = window.console = window.console || { debug: function () {} } - -const consoleDebug = console.debug -beaming.debug = function (debug) { - console.debug = debug ? consoleDebug : function () {} -} - -// Silence debug logging by default since it can affect performance -beaming.debug(params.has('debug') ?? false) - -// Feedback module -const doorbellOptions = window.doorbellOptions - -const elements = Object.freeze({ - dialog: document.getElementById('dialog'), - feedback: document.getElementById('feedback'), - feedbackContainer: document.getElementById('feedback-container'), - help: document.getElementById('help'), - info: document.getElementById('info'), - main: document.getElementById('main'), - message: document.getElementById('message'), - next: document.getElementById('next'), - previous: document.getElementById('previous'), - puzzle: document.getElementById('puzzle'), - puzzleId: document.getElementById('puzzle-id'), - redo: document.getElementById('redo'), - reset: document.getElementById('reset'), - title: document.querySelector('title'), - undo: document.getElementById('undo') -}) - -const puzzle = beaming.puzzle = new Puzzle(elements.puzzle) - -elements.feedback.addEventListener('click', () => { - elements.help.setAttribute('open', 'true') - elements.feedbackContainer.scrollIntoView(true) -}) - -elements.info.addEventListener('click', () => { - if (!elements.dialog.open) { - elements.dialog.showModal() - } -}) - -elements.next.addEventListener('click', puzzle.next.bind(puzzle)) -elements.previous.addEventListener('click', puzzle.previous.bind(puzzle)) -elements.redo.addEventListener('click', puzzle.redo.bind(puzzle)) -elements.reset.addEventListener('click', puzzle.reset.bind(puzzle)) -elements.undo.addEventListener('click', puzzle.undo.bind(puzzle)) - -// Generate puzzle ID dropdown -for (const id of Puzzles.visible.ids) { - const option = document.createElement('option') - option.value = id - option.innerText = Puzzles.titles[id] - elements.puzzleId.append(option) -} - -elements.puzzleId.addEventListener('change', (event) => puzzle.select(event.target.value)) - -document.addEventListener(Puzzle.Events.Updated, (event) => { - const state = event.detail.state - const id = state.getId() - const title = state.getTitle() - - doorbellOptions.properties.puzzleId = id - - elements.title.textContent = `Beaming: Puzzle ${title}` - - removeClass('disabled', ...Array.from(document.querySelectorAll('#actions li'))) - - const disable = [] - - if (!state.canUndo()) { - disable.push(elements.undo) - } - - if (!state.canRedo()) { - disable.push(elements.redo) - } - - if (!Puzzles.visible.has(id)) { - // Custom puzzle - elements.puzzleId.value = '' - disable.push(elements.previous, elements.next) - } else { - elements.puzzleId.value = id - - if (id === Puzzles.visible.firstId) { - disable.push(elements.previous) - } else if (id === Puzzles.visible.lastId) { - disable.push(elements.next) - } - } - - addClass('disabled', ...disable) -}) - -function resize () { - const { width, height } = elements.main.getBoundingClientRect() - elements.puzzle.style.height = height + 'px' - elements.puzzle.style.width = width + 'px' - - if (paper.view?.viewSize) { - paper.view.viewSize = new Size(width, height) - } -} - -// Handle canvas resize -window.addEventListener('resize', debounce(resize)) -resize() - -// Handle zoom -// TODO add mobile support for pinch/zoom -// See: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures -elements.puzzle.addEventListener('wheel', (event) => { - event.preventDefault() - - const zoom = paper.view.zoom * (event.deltaY > 0 ? 0.95 : 1.05) - - // Don't allow zooming too far in or out - if (zoom > 2 || zoom < 0.5) { - return - } - - // Convert the mouse point from the view coordinate space to the project coordinate space - const mousePoint = paper.view.viewToProject(new Point(event.offsetX, event.offsetY)) - const mouseOffset = mousePoint.subtract(paper.view.center) - - // Adjust center towards cursor location - const zoomOffset = mousePoint - .subtract(mouseOffset.multiply(paper.view.zoom / zoom)) - .subtract(paper.view.center) - - paper.view.zoom = zoom - paper.view.center = paper.view.center.add(zoomOffset) -}, { passive: false }) - -// Prevent browser context menu on right click -document.body.addEventListener('contextmenu', (event) => { - if (!elements.dialog.open) { - event.preventDefault() - return false - } -}) - -// Initialize -puzzle.select() +const puzzle = new Puzzle() +const beaming = { debug, puzzle } // Used by functional tests beaming.centerOnTile = function (r, c) { return puzzle.centerOnTile(new OffsetCoordinates(r, c)) } -// Useful for debug purposes +beaming.clearDebugPoints = puzzle.clearDebugPoints.bind(puzzle) beaming.drawDebugPoint = function (x, y, style) { return puzzle.drawDebugPoint(new Point(x, y), style) } + +// Export +window.beaming = beaming diff --git a/src/styles.css b/src/styles.css index c43c577..1f1f133 100644 --- a/src/styles.css +++ b/src/styles.css @@ -22,6 +22,9 @@ body { canvas { display: block; + position: relative; + touch-action: none; + z-index: 0; } code { @@ -138,6 +141,8 @@ body > header { box-shadow: 0 0 0.75rem 0.5rem rgba(0, 0, 0, 0.25); display: flex; min-height: 3.5em; + position: relative; + z-index: 1; } body > header { @@ -342,6 +347,10 @@ main { color: white; } +.modifiers { + user-select: none; +} + .puzzle-error .flex-left, .puzzle-error .flex-right { flex: 0; diff --git a/test/fixtures.js b/test/fixtures.js index 1c8a740..3f25135 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -46,7 +46,7 @@ class PuzzleFixture { const origin = this.#getModifier(name) const actions = this.driver.actions({ async: true }).move({ origin }) for (let i = 0; i < times; i++) { - actions[options.right ? 'contextClick' : 'click']() + actions.press(options.button).release(options.button) } await actions.perform() } diff --git a/test/puzzles/005.js b/test/puzzles/005.js index 66da654..5c7eb04 100644 --- a/test/puzzles/005.js +++ b/test/puzzles/005.js @@ -1,6 +1,7 @@ /* eslint-env mocha */ const { PuzzleFixture } = require('../fixtures.js') const assert = require('assert') +const { Button } = require('selenium-webdriver') describe('Puzzle 005', function () { const puzzle = new PuzzleFixture('005') @@ -15,7 +16,7 @@ describe('Puzzle 005', function () { await puzzle.clickTile(1, 0) await puzzle.clickTile(2, 0) - await puzzle.clickModifier('rotate', { right: true }) + await puzzle.clickModifier('rotate', { button: Button.MIDDLE }) await puzzle.clickModifier('rotate', { times: 2 }) await puzzle.clickModifier('swap') await puzzle.clickTile(1, 0) diff --git a/test/puzzles/008.js b/test/puzzles/008.js index 743d37e..93d251d 100644 --- a/test/puzzles/008.js +++ b/test/puzzles/008.js @@ -1,6 +1,7 @@ /* eslint-env mocha */ const { PuzzleFixture } = require('../fixtures.js') const assert = require('assert') +const { Button } = require('selenium-webdriver') describe('Puzzle 008', function () { const puzzle = new PuzzleFixture('008') @@ -20,7 +21,7 @@ describe('Puzzle 008', function () { await puzzle.clickTile(1, 0) await puzzle.clickModifier('move') await puzzle.clickTile(0, 1) - await puzzle.clickModifier('rotate', { right: true }) + await puzzle.clickModifier('rotate', { button: Button.MIDDLE }) await puzzle.clickModifier('rotate') await puzzle.clickTile(2, 4) @@ -39,13 +40,13 @@ describe('Puzzle 008', function () { await puzzle.clickTile(2, 4) await puzzle.clickModifier('swap') await puzzle.clickTile(0, 1) - await puzzle.clickModifier('rotate', { right: true }) + await puzzle.clickModifier('rotate', { button: Button.MIDDLE }) await puzzle.clickModifier('rotate', { times: 2 }) await puzzle.clickTile(2, 4) await puzzle.clickModifier('swap') await puzzle.clickTile(0, 1) - await puzzle.clickModifier('rotate', { right: true }) + await puzzle.clickModifier('rotate', { button: Button.MIDDLE }) await puzzle.clickModifier('rotate') await puzzle.clickTile(1, 1) @@ -55,7 +56,7 @@ describe('Puzzle 008', function () { await puzzle.clickTile(0, 0) await puzzle.clickModifier('swap') await puzzle.clickTile(0, 1) - await puzzle.clickModifier('rotate', { right: true }) + await puzzle.clickModifier('rotate', { button: Button.MIDDLE }) await puzzle.clickModifier('rotate', { times: 2 }) await puzzle.clickTile(0, 0)