useGameItemActions.js 19 KB

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