useGameItemActions.js 18 KB

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