From 5a386fc1823356931e0b367b43cd8ecd2c528228 Mon Sep 17 00:00:00 2001 From: Alex Mubarakshin Date: Sat, 22 Jun 2024 12:09:12 +0300 Subject: [PATCH 1/2] Improve Keyboard Interaction for Player Component This commit enhances the keyboard controls within the Player component, specifically addressing an issue where the combination of CMD (on macOS) or Control (on other platforms) keys with movement keys (e.g., arrow keys) did not function as expected. The update ensures a smoother and more intuitive control scheme for users, facilitating better gameplay experience by allowing simultaneous use of command keys with movement controls. --- frontend/src/components/Game.tsx | 21 +---- frontend/src/components/Player.tsx | 13 ++- frontend/src/constants.ts | 9 ++ frontend/src/hooks/useKeyboardControls.tsx | 105 +++++++++++++++++++++ frontend/src/hooks/useWindowListener.ts | 44 +++++++++ 5 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 frontend/src/hooks/useKeyboardControls.tsx create mode 100644 frontend/src/hooks/useWindowListener.ts diff --git a/frontend/src/components/Game.tsx b/frontend/src/components/Game.tsx index 4c8013d..a855d89 100644 --- a/frontend/src/components/Game.tsx +++ b/frontend/src/components/Game.tsx @@ -1,14 +1,13 @@ import { cssObj } from '@fuel-ui/css'; import { Box, Button } from '@fuel-ui/react'; -import type { KeyboardControlsEntry } from '@react-three/drei'; -import { KeyboardControls } from '@react-three/drei'; import { Canvas } from '@react-three/fiber'; import { BN } from 'fuels'; import type { BytesLike } from 'fuels'; -import { useState, useEffect, useMemo, Suspense } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import type { Modals } from '../constants'; -import { Controls, buttonStyle, FoodTypeInput } from '../constants'; +import { buttonStyle, FoodTypeInput, ControlsMap } from '../constants'; +import { KeyboardControlsProvider } from '../hooks/useKeyboardControls'; import type { AddressInput, ContractAbi, @@ -111,16 +110,6 @@ export default function Game({ setUpdateNum(updateNum + 1); } - const controlsMap = useMemo( - () => [ - { name: Controls.forward, keys: ['ArrowUp', 'w', 'W'] }, - { name: Controls.back, keys: ['ArrowDown', 's', 'S'] }, - { name: Controls.left, keys: ['ArrowLeft', 'a', 'A'] }, - { name: Controls.right, keys: ['ArrowRight', 'd', 'D'] }, - ], - [] - ); - return ( {status === 'error' && ( @@ -155,7 +144,7 @@ export default function Game({ {/* PLAYER */} {player !== null && ( - + - + )} diff --git a/frontend/src/components/Player.tsx b/frontend/src/components/Player.tsx index 21b372e..c741664 100644 --- a/frontend/src/components/Player.tsx +++ b/frontend/src/components/Player.tsx @@ -1,12 +1,13 @@ -import { useKeyboardControls } from '@react-three/drei'; import { useFrame, useLoader } from '@react-three/fiber'; import type { Dispatch, SetStateAction } from 'react'; import { useState, useEffect, useRef } from 'react'; import type { Texture, Sprite } from 'three'; import { Vector3, TextureLoader, NearestFilter } from 'three'; -import type { Modals, Controls } from '../constants'; +import type { Modals } from '../constants'; import { convertTime, TILES } from '../constants'; +import type { KeyboardControlsState } from '../hooks/useKeyboardControls'; +import { useKeyboardControls } from '../hooks/useKeyboardControls'; import type { GardenVectorOutput } from '../sway-api/contracts/ContractAbi'; import type { MobileControls, Position } from './Game'; @@ -60,7 +61,7 @@ export default function Player({ const [currentTile, setCurrentTile] = useState(0); const [spriteMap, setSpriteMap] = useState(); const ref = useRef(null); - const [, get] = useKeyboardControls(); + const controls = useKeyboardControls(); const tilesHoriz = 4; const tilesVert = 5; @@ -80,11 +81,10 @@ export default function Player({ const velocity = new Vector3(); useFrame((_s, dl) => { - const state = get(); checkTiles(); updateCameraPosition(); - if (canMove) movePlayer(dl, state, mobileControlState); + if (canMove) movePlayer(dl, controls, mobileControlState); }); function updateCameraPosition() { @@ -185,8 +185,7 @@ export default function Player({ function movePlayer( dl: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: any, + state: KeyboardControlsState, mobileControlState: MobileControls ) { if (!ref.current) return; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index d8378b0..d60954d 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -2,6 +2,8 @@ import { cssObj } from '@fuel-ui/css'; import type { Asset, BN, NetworkFuel } from 'fuels'; import { Vector3 } from 'three'; +import type { KeyboardControlsEntry } from './hooks/useKeyboardControls'; + // import contractIds from './sway-api/contract-ids.json'; export const FUEL_PROVIDER_URL = 'https://testnet.fuel.network/v1/graphql'; @@ -49,6 +51,13 @@ export enum Controls { back = 'back', } +export const ControlsMap: KeyboardControlsEntry[] = [ + { name: Controls.forward, keys: ['ArrowUp', 'w', 'W'] }, + { name: Controls.back, keys: ['ArrowDown', 's', 'S'] }, + { name: Controls.left, keys: ['ArrowLeft', 'a', 'A'] }, + { name: Controls.right, keys: ['ArrowRight', 'd', 'D'] }, +]; + export const TILES = [ new Vector3(-2.47, -0.88, 0), new Vector3(-1.23, -0.88, 0), diff --git a/frontend/src/hooks/useKeyboardControls.tsx b/frontend/src/hooks/useKeyboardControls.tsx new file mode 100644 index 0000000..d41d914 --- /dev/null +++ b/frontend/src/hooks/useKeyboardControls.tsx @@ -0,0 +1,105 @@ +import React, { + useState, + createContext, + useContext, + useMemo, + useCallback, +} from 'react'; + +import { useWindowListener } from './useWindowListener'; + +export const Controls = { + forward: 'forward', + back: 'back', + left: 'left', + right: 'right', +} as const; + +export type ControlsType = (typeof Controls)[keyof typeof Controls]; + +export type KeyboardControlsEntry = { + name: ControlsType; + keys: string[]; + up?: boolean; +}; + +export type KeyboardControlsState = { [key in ControlsType]: boolean }; + +const KeyboardControlsContext = createContext< + KeyboardControlsState | undefined +>(undefined); + +export const useKeyboardControls = () => { + const context = useContext(KeyboardControlsContext); + if (!context) { + throw new Error( + 'useKeyboardControls must be used within a KeyboardControlsProvider' + ); + } + return context; +}; + +type KeyboardControlsProviderProps = { + map: KeyboardControlsEntry[]; + children: React.ReactNode; +}; + +export const KeyboardControlsProvider: React.FC< + KeyboardControlsProviderProps +> = ({ map, children }) => { + const [state, setState] = useState( + map.reduce( + (acc, cur) => ({ ...acc, [cur.name]: false }), + {} as KeyboardControlsState + ) + ); + + const keyMap = useMemo( + () => + map.reduce( + (acc, { name, keys }) => { + keys.forEach((key) => { + acc[key] = name; + }); + return acc; + }, + {} as { [key: string]: ControlsType } + ), + [map] + ); + + const downHandler = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey) return; // Ignore if cmd/meta key is pressed + + const controlName = keyMap[event.key]; + if (controlName && !state[controlName]) { + event.preventDefault(); + setState((prevState) => ({ ...prevState, [controlName]: true })); + } + }, + [keyMap, state] + ); + + const upHandler = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey) return; // Ignore if cmd/meta key is pressed + + const controlName = keyMap[event.key]; + if (controlName && state[controlName]) { + event.preventDefault(); + setState((prevState) => ({ ...prevState, [controlName]: false })); + } + }, + [keyMap, state] + ); + + useWindowListener('keydown', downHandler); + useWindowListener('keyup', upHandler); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/hooks/useWindowListener.ts b/frontend/src/hooks/useWindowListener.ts new file mode 100644 index 0000000..d670195 --- /dev/null +++ b/frontend/src/hooks/useWindowListener.ts @@ -0,0 +1,44 @@ +import { useRef, useEffect } from 'react'; + +/** + * A custom React hook that allows you to easily add and remove event listeners to the `window` object. + * This hook ensures that the event listener is properly cleaned up when the component unmounts or the event changes. + * It also handles the potential issue of stale closures by using a ref to keep the callback function up to date. + * + * @template E The type of event that this hook will listen for. It extends the base `Event` type. + * @param {string} event - The name of the event to listen for on the window object. For example: 'resize', 'scroll', etc. + * @param {(e: E) => void} callback - The callback function that will be executed when the event is triggered. + * The callback receives the event object as its parameter. + * + * @example + * // Example of using useWindowListener to add a resize event listener + * useWindowListener('resize', (e) => { + * console.log('Window resized', e); + * }); + * + * @example + * // Example of using useWindowListener with a custom event type + * useWindowListener('myCustomEvent', (e) => { + * console.log('Custom event triggered', e.detail); + * }); + */ +export const useWindowListener = ( + event: string, + callback: (e: E) => void +) => { + // useRef is used to hold a reference to the callback. This approach ensures that + // the callback can be updated without re-adding the event listener, reducing unnecessary operations. + const ref = useRef(callback); + + useEffect(() => { + ref.current = callback; + }, [callback]); + + useEffect(() => { + const handler = (e: Event) => ref.current(e as E); + window.addEventListener(event, handler); + return () => { + window.removeEventListener(event, handler); + }; + }, [event]); +}; From 4289805940b5ef2d655b77c877a70839f7ea62e7 Mon Sep 17 00:00:00 2001 From: Alex Mubarakshin Date: Sat, 22 Jun 2024 13:00:41 +0300 Subject: [PATCH 2/2] Reset keyboard controls state when meta key is pressed Reset the state of keyboard controls when the meta key (CMD on macOS) is pressed. --- frontend/src/hooks/useKeyboardControls.tsx | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useKeyboardControls.tsx b/frontend/src/hooks/useKeyboardControls.tsx index d41d914..5faafa4 100644 --- a/frontend/src/hooks/useKeyboardControls.tsx +++ b/frontend/src/hooks/useKeyboardControls.tsx @@ -70,7 +70,21 @@ export const KeyboardControlsProvider: React.FC< const downHandler = useCallback( (event: KeyboardEvent) => { - if (event.metaKey) return; // Ignore if cmd/meta key is pressed + // Reset the state if the meta key is pressed + if (event.metaKey || event.key === 'Meta') { + event.preventDefault(); + + setState((prevState) => + Object.keys(prevState).reduce( + (acc, key) => ({ + ...acc, + [key]: false, + }), + {} as KeyboardControlsState + ) + ); + return; + } const controlName = keyMap[event.key]; if (controlName && !state[controlName]) { @@ -83,7 +97,22 @@ export const KeyboardControlsProvider: React.FC< const upHandler = useCallback( (event: KeyboardEvent) => { - if (event.metaKey) return; // Ignore if cmd/meta key is pressed + // Reset the state if the meta key is pressed + if (event.key === 'Meta') { + event.preventDefault(); + + setState((prevState) => + Object.keys(prevState).reduce( + (acc, key) => ({ + ...acc, + [key]: false, + }), + {} as KeyboardControlsState + ) + ); + + return; + } const controlName = keyMap[event.key]; if (controlName && state[controlName]) {