useGameItemActionMap.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. import React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import { nanoid } from "nanoid";
  4. import { toast } from "react-toastify";
  5. import { useSetRecoilState, useRecoilCallback } from "recoil";
  6. import { useItemBaseActions } from "../components/board/Items";
  7. import { SelectedItemsAtom } from "../components/board";
  8. import { useUsers } from "../components/users";
  9. import { ItemMapAtom } from "../components/board";
  10. import { shuffle as shuffleArray, randInt } from "../components/utils";
  11. import deleteIcon from "../images/delete.svg";
  12. import stackToCenterIcon from "../images/stackToCenter.svg";
  13. import stackToTopLeftIcon from "../images/stackToTopLeft.svg";
  14. import alignAsLineIcon from "../images/alignAsLine.svg";
  15. import alignAsSquareIcon from "../images/alignAsSquare.svg";
  16. import duplicateIcon from "../images/duplicate.svg";
  17. import seeIcon from "../images/see.svg";
  18. import flipIcon from "../images/flip.svg";
  19. import lockIcon from "../images/lock.svg";
  20. import rotateIcon from "../images/rotate.svg";
  21. import shuffleIcon from "../images/shuffle.svg";
  22. import tapIcon from "../images/tap.svg";
  23. import useLocalStorage from "../hooks/useLocalStorage";
  24. export const useGameItemActionMap = () => {
  25. const {
  26. batchUpdateItems,
  27. removeItems,
  28. insertItemBefore,
  29. reverseItemsOrder,
  30. swapItems,
  31. } = useItemBaseActions();
  32. const { t } = useTranslation();
  33. const [isFirstLock, setIsFirstLock] = useLocalStorage("isFirstLock", true);
  34. const { currentUser } = useUsers();
  35. const setSelectedItems = useSetRecoilState(SelectedItemsAtom);
  36. const isMountedRef = React.useRef(false);
  37. const getItemListOrSelected = useRecoilCallback(
  38. ({ snapshot }) => async (itemIds) => {
  39. const itemMap = await snapshot.getPromise(ItemMapAtom);
  40. if (itemIds) {
  41. return [itemIds, itemIds.map((id) => itemMap[id])];
  42. } else {
  43. const selectedItems = await snapshot.getPromise(SelectedItemsAtom);
  44. return [selectedItems, selectedItems.map((id) => itemMap[id])];
  45. }
  46. },
  47. []
  48. );
  49. React.useEffect(() => {
  50. // Mounted guard
  51. isMountedRef.current = true;
  52. return () => {
  53. isMountedRef.current = false;
  54. };
  55. }, []);
  56. // Stack selection to Center
  57. const stackToCenter = React.useCallback(
  58. async (
  59. itemIds,
  60. {
  61. stackThicknessMin = 0.5,
  62. stackThicknessMax = 1,
  63. limitCardsNumber = 32,
  64. } = {}
  65. ) => {
  66. const [ids, items] = await getItemListOrSelected(itemIds);
  67. // Rule to manage thickness of the stack.
  68. let stackThickness = stackThicknessMax;
  69. if (items.length >= limitCardsNumber) {
  70. stackThickness = stackThicknessMin;
  71. }
  72. // To avoid displacement effects.
  73. let isSameGap = true;
  74. for (let i = 1; i < items.length; i++) {
  75. if (Math.abs(items[i].x - items[i - 1].x) != stackThickness) {
  76. isSameGap = false;
  77. break;
  78. }
  79. if (Math.abs(items[i].y - items[i - 1].y) != stackThickness) {
  80. isSameGap = false;
  81. break;
  82. }
  83. }
  84. if (isSameGap == true) {
  85. return;
  86. }
  87. // Compute middle position
  88. const minMax = { min: {}, max: {} };
  89. minMax.min.x = Math.min(...items.map(({ x }) => x));
  90. minMax.min.y = Math.min(...items.map(({ y }) => y));
  91. minMax.max.x = Math.max(
  92. ...items.map(({ x, id }) => x + document.getElementById(id).clientWidth)
  93. );
  94. minMax.max.y = Math.max(
  95. ...items.map(
  96. ({ y, id }) => y + document.getElementById(id).clientHeight
  97. )
  98. );
  99. const { clientWidth, clientHeight } = document.getElementById(
  100. items[0].id
  101. );
  102. let newX =
  103. minMax.min.x + (minMax.max.x - minMax.min.x) / 2 - clientWidth / 2;
  104. let newY =
  105. minMax.min.y + (minMax.max.y - minMax.min.y) / 2 - clientHeight / 2;
  106. batchUpdateItems(ids, (item) => {
  107. const newItem = {
  108. ...item,
  109. x: newX,
  110. y: newY,
  111. };
  112. newX += stackThickness;
  113. newY -= stackThickness;
  114. return newItem;
  115. });
  116. },
  117. [batchUpdateItems, getItemListOrSelected]
  118. );
  119. // Stack selection to Top Left
  120. const stackToTopLeft = React.useCallback(
  121. async (
  122. itemIds,
  123. {
  124. stackThicknessMin = 0.5,
  125. stackThicknessMax = 1,
  126. limitCardsNumber = 32,
  127. } = {}
  128. ) => {
  129. const [ids, items] = await getItemListOrSelected(itemIds);
  130. let { x: newX, y: newY } = items[0];
  131. // Rule to manage thickness of the stack.
  132. let stackThickness = stackThicknessMax;
  133. if (items.length >= limitCardsNumber) {
  134. stackThickness = stackThicknessMin;
  135. }
  136. batchUpdateItems(ids, (item) => {
  137. const newItem = {
  138. ...item,
  139. x: newX,
  140. y: newY,
  141. };
  142. newX += stackThickness;
  143. newY -= stackThickness;
  144. return newItem;
  145. });
  146. },
  147. [batchUpdateItems, getItemListOrSelected]
  148. );
  149. // Align selection to a line
  150. const alignAsLine = React.useCallback(
  151. async (itemIds, { gapBetweenItems = 5 } = {}) => {
  152. // Negative value is possible for 'gapBetweenItems'.
  153. const [ids, items] = await getItemListOrSelected(itemIds);
  154. let { x: newX, y: newY } = items[0];
  155. batchUpdateItems(ids, (item) => {
  156. const { clientWidth } = document.getElementById(item.id);
  157. const newItem = {
  158. ...item,
  159. x: newX,
  160. y: newY,
  161. };
  162. newX += clientWidth + gapBetweenItems;
  163. return newItem;
  164. });
  165. },
  166. [getItemListOrSelected, batchUpdateItems]
  167. );
  168. // Align selection to an array
  169. const alignAsSquare = React.useCallback(
  170. async (itemIds, { gapBetweenItems = 5 } = {}) => {
  171. // Negative value is possible for 'gapBetweenItems'.
  172. const [ids, items] = await getItemListOrSelected(itemIds);
  173. // Count number of elements
  174. const numberOfElements = items.length;
  175. const numberOfColumns = Math.ceil(Math.sqrt(numberOfElements));
  176. let { x: newX, y: newY } = items[0];
  177. let currentColumn = 1;
  178. batchUpdateItems(ids, (item) => {
  179. const { clientWidth, clientHeight } = document.getElementById(item.id);
  180. const newItem = {
  181. ...item,
  182. x: newX,
  183. y: newY,
  184. };
  185. newX += clientWidth + gapBetweenItems;
  186. currentColumn += 1;
  187. if (currentColumn > numberOfColumns) {
  188. currentColumn = 1;
  189. newX = items[0].x;
  190. newY += clientHeight + gapBetweenItems;
  191. }
  192. return newItem;
  193. });
  194. },
  195. [getItemListOrSelected, batchUpdateItems]
  196. );
  197. const shuffleItems = React.useCallback(
  198. async (itemIds) => {
  199. const [ids] = await getItemListOrSelected(itemIds);
  200. ids.forEach((itemId) => {
  201. const elem = document.getElementById(itemId);
  202. elem.firstChild.className = "hvr-wobble-horizontal";
  203. });
  204. const shuffledItems = shuffleArray([...ids]);
  205. swapItems(ids, shuffledItems);
  206. },
  207. [getItemListOrSelected, swapItems]
  208. );
  209. const randomlyRotateSelectedItems = React.useCallback(
  210. async (itemIds, { angle, maxRotateCount }) => {
  211. const [ids] = await getItemListOrSelected(itemIds);
  212. batchUpdateItems(ids, (item) => {
  213. const rotation =
  214. ((item.rotation || 0) + angle * randInt(0, maxRotateCount)) % 360;
  215. return { ...item, rotation };
  216. });
  217. },
  218. [getItemListOrSelected, batchUpdateItems]
  219. );
  220. // Tap/Untap elements
  221. const toggleTap = React.useCallback(
  222. async (itemIds) => {
  223. const [ids, items] = await getItemListOrSelected(itemIds);
  224. const tappedCount = items.filter(({ rotation }) => rotation === 90)
  225. .length;
  226. let untap = false;
  227. if (tappedCount > ids.length / 2) {
  228. untap = true;
  229. }
  230. batchUpdateItems(ids, (item) => ({
  231. ...item,
  232. rotation: untap ? 0 : 90,
  233. }));
  234. },
  235. [getItemListOrSelected, batchUpdateItems]
  236. );
  237. // Lock / unlock elements
  238. const toggleLock = React.useCallback(
  239. async (itemIds) => {
  240. const [ids] = await getItemListOrSelected(itemIds);
  241. batchUpdateItems(ids, (item) => ({
  242. ...item,
  243. locked: !item.locked,
  244. }));
  245. // Help user on first lock
  246. if (isFirstLock) {
  247. toast.info(
  248. t("You've locked your first element. Long click to select it again."),
  249. { autoClose: false }
  250. );
  251. setIsFirstLock(false);
  252. }
  253. },
  254. [getItemListOrSelected, batchUpdateItems, isFirstLock, t, setIsFirstLock]
  255. );
  256. // Flip or reveal items
  257. const setFlip = React.useCallback(
  258. async (itemIds, { flip = true, reverseOrder = true } = {}) => {
  259. batchUpdateItems(itemIds, (item) => ({
  260. ...item,
  261. flipped: flip,
  262. unflippedFor:
  263. !Array.isArray(item.unflippedFor) || item.unflippedFor.length > 0
  264. ? null
  265. : item.unflippedFor,
  266. }));
  267. if (reverseOrder) {
  268. reverseItemsOrder(itemIds);
  269. setSelectedItems((prev) => {
  270. const reversed = [...prev];
  271. reversed.reverse();
  272. return reversed;
  273. });
  274. }
  275. },
  276. [batchUpdateItems, reverseItemsOrder, setSelectedItems]
  277. );
  278. // Toggle flip state
  279. const toggleFlip = React.useCallback(
  280. async (itemIds, { reverseOrder = true } = {}) => {
  281. const [ids, items] = await getItemListOrSelected(itemIds);
  282. const flippedCount = items.filter(({ flipped }) => flipped).length;
  283. setFlip(ids, {
  284. flip: flippedCount < ids.length / 2,
  285. reverseOrder,
  286. });
  287. },
  288. [getItemListOrSelected, setFlip]
  289. );
  290. // Rotate element
  291. const rotate = React.useCallback(
  292. async (itemIds, { angle }) => {
  293. const [ids] = await getItemListOrSelected(itemIds);
  294. batchUpdateItems(ids, (item) => ({
  295. ...item,
  296. rotation: (item.rotation || 0) + angle,
  297. }));
  298. },
  299. [getItemListOrSelected, batchUpdateItems]
  300. );
  301. // Reveal for player only
  302. const setFlipSelf = React.useCallback(
  303. async (itemIds, { flipSelf = true } = {}) => {
  304. batchUpdateItems(itemIds, (item) => {
  305. let { unflippedFor = [] } = item;
  306. if (!Array.isArray(item.unflippedFor)) {
  307. unflippedFor = [];
  308. }
  309. if (flipSelf && !unflippedFor.includes(currentUser.uid)) {
  310. unflippedFor = [...unflippedFor, currentUser.uid];
  311. }
  312. if (!flipSelf && unflippedFor.includes(currentUser.uid)) {
  313. unflippedFor = unflippedFor.filter((id) => id !== currentUser.uid);
  314. }
  315. return {
  316. ...item,
  317. flipped: true,
  318. unflippedFor,
  319. };
  320. });
  321. },
  322. [batchUpdateItems, currentUser.uid]
  323. );
  324. // Reveal for player only
  325. const toggleFlipSelf = React.useCallback(
  326. async (itemIds) => {
  327. const [ids, items] = await getItemListOrSelected(itemIds);
  328. const flippedSelfCount = items.filter(
  329. ({ unflippedFor }) =>
  330. Array.isArray(unflippedFor) && unflippedFor.includes(currentUser.uid)
  331. ).length;
  332. let flipSelf = true;
  333. if (flippedSelfCount > ids.length / 2) {
  334. flipSelf = false;
  335. }
  336. setFlipSelf(ids, { flipSelf });
  337. },
  338. [getItemListOrSelected, setFlipSelf, currentUser.uid]
  339. );
  340. const remove = React.useCallback(
  341. async (itemIds) => {
  342. const [ids] = await getItemListOrSelected(itemIds);
  343. removeItems(ids);
  344. },
  345. [getItemListOrSelected, removeItems]
  346. );
  347. const cloneItem = React.useCallback(
  348. async (itemIds) => {
  349. const [, items] = await getItemListOrSelected(itemIds);
  350. items.forEach((itemToClone) => {
  351. const newItem = JSON.parse(JSON.stringify(itemToClone));
  352. newItem.id = nanoid();
  353. delete newItem.move;
  354. insertItemBefore(newItem, itemToClone.id);
  355. });
  356. },
  357. [getItemListOrSelected, insertItemBefore]
  358. );
  359. const actionMap = React.useMemo(
  360. () => ({
  361. flip: {
  362. action: toggleFlip,
  363. label: t("Reveal") + "/" + t("Hide"),
  364. shortcut: "f",
  365. icon: flipIcon,
  366. },
  367. reveal: {
  368. action: (itemIds) => setFlip(itemIds, { flip: false }),
  369. label: t("Reveal"),
  370. icon: flipIcon,
  371. },
  372. hide: {
  373. action: (itemIds) => setFlip(itemIds, { flip: true }),
  374. label: t("Hide"),
  375. icon: flipIcon,
  376. },
  377. flipSelf: {
  378. action: toggleFlipSelf,
  379. label: t("Reveal for me"),
  380. shortcut: "o",
  381. icon: seeIcon,
  382. },
  383. revealSelf: {
  384. action: (itemIds) => setFlipSelf(itemIds, { flipSelf: true }),
  385. label: t("Reveal for me"),
  386. icon: seeIcon,
  387. },
  388. hideSelf: {
  389. action: (itemIds) => setFlipSelf(itemIds, { flipSelf: false }),
  390. label: t("Hide for me"),
  391. icon: seeIcon,
  392. },
  393. tap: {
  394. action: toggleTap,
  395. label: t("Tap") + "/" + t("Untap"),
  396. shortcut: "t",
  397. icon: tapIcon,
  398. },
  399. stackToCenter: {
  400. action: stackToCenter,
  401. label: t("Stack To Center"),
  402. shortcut: "",
  403. multiple: true,
  404. icon: stackToCenterIcon,
  405. },
  406. stack: {
  407. action: stackToTopLeft,
  408. label: t("Stack To Top Left"),
  409. multiple: true,
  410. icon: stackToTopLeftIcon,
  411. },
  412. alignAsLine: {
  413. action: alignAsLine,
  414. label: t("Align as line"),
  415. multiple: true,
  416. icon: alignAsLineIcon,
  417. },
  418. alignAsSquare: {
  419. action: alignAsSquare,
  420. label: t("Align as square"),
  421. multiple: true,
  422. icon: alignAsSquareIcon,
  423. },
  424. shuffle: {
  425. action: shuffleItems,
  426. label: t("Shuffle"),
  427. multiple: true,
  428. icon: shuffleIcon,
  429. },
  430. randomlyRotate30: {
  431. action: (itemIds) =>
  432. randomlyRotateSelectedItems(itemIds, {
  433. angle: 30,
  434. maxRotateCount: 11,
  435. }),
  436. label: t("Rotate randomly 30"),
  437. multiple: false,
  438. icon: rotateIcon,
  439. },
  440. randomlyRotate45: {
  441. action: (itemIds) =>
  442. randomlyRotateSelectedItems(itemIds, {
  443. angle: 45,
  444. maxRotateCount: 7,
  445. }),
  446. label: t("Rotate randomly 45"),
  447. shortcut: "",
  448. multiple: false,
  449. icon: rotateIcon,
  450. },
  451. randomlyRotate60: {
  452. action: (itemIds) =>
  453. randomlyRotateSelectedItems(itemIds, {
  454. angle: 60,
  455. maxRotateCount: 5,
  456. }),
  457. label: t("Rotate randomly 60"),
  458. shortcut: "",
  459. multiple: false,
  460. icon: rotateIcon,
  461. },
  462. randomlyRotate90: {
  463. action: (itemIds) =>
  464. randomlyRotateSelectedItems(itemIds, {
  465. angle: 90,
  466. maxRotateCount: 3,
  467. }),
  468. label: t("Rotate randomly 90"),
  469. shortcut: "",
  470. multiple: false,
  471. icon: rotateIcon,
  472. },
  473. randomlyRotate180: {
  474. action: (itemIds) =>
  475. randomlyRotateSelectedItems(itemIds, {
  476. angle: 180,
  477. maxRotateCount: 1,
  478. }),
  479. label: t("Rotate randomly 180"),
  480. shortcut: "",
  481. multiple: false,
  482. icon: rotateIcon,
  483. },
  484. rotate30: {
  485. action: (itemIds) => rotate(itemIds, { angle: 30 }),
  486. label: t("Rotate 30"),
  487. shortcut: "r",
  488. icon: rotateIcon,
  489. },
  490. rotate45: {
  491. action: (itemIds) => rotate(itemIds, { angle: 45 }),
  492. label: t("Rotate 45"),
  493. shortcut: "r",
  494. icon: rotateIcon,
  495. },
  496. rotate60: {
  497. action: (itemIds) => rotate(itemIds, { angle: 60 }),
  498. label: t("Rotate 60"),
  499. shortcut: "r",
  500. icon: rotateIcon,
  501. },
  502. rotate90: {
  503. action: (itemIds) => rotate(itemIds, { angle: 90 }),
  504. label: t("Rotate 90"),
  505. shortcut: "r",
  506. icon: rotateIcon,
  507. },
  508. rotate180: {
  509. action: (itemIds) => rotate(itemIds, { angle: 180 }),
  510. label: t("Rotate 180"),
  511. shortcut: "r",
  512. icon: rotateIcon,
  513. },
  514. clone: {
  515. action: cloneItem,
  516. label: t("Clone"),
  517. shortcut: "c",
  518. disableDblclick: true,
  519. edit: true,
  520. icon: duplicateIcon,
  521. },
  522. lock: {
  523. action: toggleLock,
  524. label: t("Unlock") + "/" + t("Lock"),
  525. disableDblclick: true,
  526. icon: lockIcon,
  527. },
  528. remove: {
  529. action: remove,
  530. label: t("Remove all"),
  531. shortcut: "Delete",
  532. edit: true,
  533. disableDblclick: true,
  534. icon: deleteIcon,
  535. },
  536. }),
  537. [
  538. toggleFlip,
  539. t,
  540. toggleFlipSelf,
  541. toggleTap,
  542. stackToCenter,
  543. stackToTopLeft,
  544. alignAsLine,
  545. alignAsSquare,
  546. shuffleItems,
  547. cloneItem,
  548. toggleLock,
  549. remove,
  550. setFlip,
  551. setFlipSelf,
  552. randomlyRotateSelectedItems,
  553. rotate,
  554. ]
  555. );
  556. return { actionMap, setFlip, setFlipSelf, stack: stackToTopLeft };
  557. };
  558. export default useGameItemActionMap;