diff --git a/src/components/GameBoard/GameBoard.test.ts b/src/components/GameBoard/GameBoard.test.ts index 59ca5ad..0b14573 100644 --- a/src/components/GameBoard/GameBoard.test.ts +++ b/src/components/GameBoard/GameBoard.test.ts @@ -6,6 +6,8 @@ import GameBoard from "./GameBoard.vue"; vi.spyOn(document, "addEventListener"); vi.spyOn(document, "removeEventListener"); +// TODO: add more tests (unit) +// TODO: add more tests (e2e) describe("GameBoard", () => { beforeEach(() => { setActivePinia(createPinia()); diff --git a/src/components/GameBoard/GameBoard.vue b/src/components/GameBoard/GameBoard.vue index 76629e6..f294717 100644 --- a/src/components/GameBoard/GameBoard.vue +++ b/src/components/GameBoard/GameBoard.vue @@ -4,7 +4,7 @@ - +
@@ -18,8 +18,9 @@ {{ gameOverDialog.message }}
@@ -37,41 +38,63 @@ import TileItem from "@/components/Tile/Item/TileItem.vue"; import GameBoardHeader from "@/components/GameBoard/Header/GameBoardHeader.vue"; import GameBoardControls from "@/components/GameBoard/Controls/GameBoardControls.vue"; -import { useGridCells } from "@/composables/useGridCells"; -import { useTiles } from "@/composables/useTiles"; +import { useGridCellsStore } from "@/stores/gridCells"; +import { useTilesStore } from "@/stores/tiles"; import { useGameStateStore } from "@/stores/gameState"; import { useUserInput } from "@/composables/useUserInput"; import { generateNumArray } from "@/utils"; -const gameStateStore = useGameStateStore(); -const { resetGridCells } = useGridCells(); -const { renderedTiles, hasReachedHighestValue, addTileToCell, setRenderedTiles } = useTiles(); -const { score, bestScore, gameOverDialog, gridSize, numObstacles } = storeToRefs(gameStateStore); -const { endGame, setCanAcceptUserInput, setScore, hideGameOverDialog } = gameStateStore; +const { gridCells } = storeToRefs(useGridCellsStore()); +const { resetGridCells } = useGridCellsStore(); +const { renderedTiles, hasReachedHighestValue } = storeToRefs(useTilesStore()); +const { addTileToCell, setRenderedTiles } = useTilesStore(); +const { score, bestScore, gameOverDialog, gridSize, numObstacles } = + storeToRefs(useGameStateStore()); +const { endGame, setCanAcceptUserInput, setScore, hideGameOverDialog } = useGameStateStore(); const { handleUserInput } = useUserInput(); -function startGame() { - // Reset game - hideGameOverDialog(); +function startNewGame() { setScore(0); - setRenderedTiles([]); resetGridCells(gridSize.value); addTileToCell(); - setCanAcceptUserInput(true); // Add obstacles generateNumArray(numObstacles.value).forEach(() => addTileToCell({ isObstacle: true })); } +function handleNewGameClick() { + // Reset game + hideGameOverDialog(); + setRenderedTiles([]); + + startNewGame(); +} + watch(hasReachedHighestValue, (current) => { if (current) { - setCanAcceptUserInput(false); endGame("win"); } }); +watch( + () => gameOverDialog.value.show, + (current) => { + setCanAcceptUserInput(!current); + }, +); + onMounted(() => { - startGame(); + const canContinueSavedGame = + renderedTiles.value.length - numObstacles.value > 1 && + renderedTiles.value.length < gridCells.value.length; + + if (!canContinueSavedGame) { + startNewGame(); + } + + setRenderedTiles(gridCells.value.filter((cell) => cell.tile).map((cell) => cell.tile!)); + setCanAcceptUserInput(true); + document.addEventListener("keyup", handleUserInput); }); diff --git a/src/composables/useGridCells.ts b/src/composables/useGridCells.ts deleted file mode 100644 index 944b828..0000000 --- a/src/composables/useGridCells.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ref, computed } from "vue"; -import { generateNumArray } from "@/utils"; - -import type { Cell, Tile } from "@/types"; - -// Shared state -const gridCells = ref([]); - -export function useGridCells() { - const _gridCellsByColumn = computed(() => { - return gridCells.value.reduce((acc, cell) => { - acc[cell.col - 1] = acc[cell.col - 1] || []; - acc[cell.col - 1][cell.row - 1] = cell; - - return acc; - }, []); - }); - - const _gridCellsByRow = computed(() => { - return gridCells.value.reduce((acc, cell) => { - acc[cell.row - 1] = acc[cell.row - 1] || []; - acc[cell.row - 1][cell.col - 1] = cell; - - return acc; - }, []); - }); - - const gridCellsByDirection = computed>(() => ({ - ArrowLeft: _gridCellsByRow.value, - ArrowRight: _gridCellsByRow.value.map((row) => [...row].reverse()), - ArrowUp: _gridCellsByColumn.value, - ArrowDown: _gridCellsByColumn.value.map((col) => [...col].reverse()), - })); - - function getRandomEmptyGridCell(): Cell { - const emptyCells = gridCells.value.filter((cell) => !cell.tile); - const randomIndex = Math.floor(Math.random() * emptyCells.length); - - return emptyCells[randomIndex]; - } - - function resetGridCells(size: number) { - gridCells.value = generateNumArray(Math.pow(size, 2)).map((index) => ({ - col: (index % size) + 1, - row: Math.floor(index / size) + 1, - })); - } - - function getTilesFromGridCells() { - return gridCells.value.reduce((acc, cell) => { - if (cell.tile) { - acc.push(cell.tile); - } - - return acc; - }, []); - } - - return { - gridCells, - gridCellsByDirection, - getRandomEmptyGridCell, - getTilesFromGridCells, - resetGridCells, - }; -} diff --git a/src/composables/useTiles.ts b/src/composables/useTiles.ts deleted file mode 100644 index 29b9f1b..0000000 --- a/src/composables/useTiles.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { computed, ref } from "vue"; -import { useGameStateStore } from "@/stores/gameState"; -import { useGridCells } from "@/composables/useGridCells"; - -import type { Cell, Tile } from "@/types"; - -interface _SetTileInCellPayload { - cell: Cell; - tile: Partial; - isTileToMerge?: boolean; -} - -const TILE_HIGHEST_VALUE = 2048; - -// Shared state -const renderedTiles = ref([]); - -export function useTiles() { - const gameStateStore = useGameStateStore(); - const { getRandomEmptyGridCell } = useGridCells(); - - const hasReachedHighestValue = computed(() => { - return Math.max(...renderedTiles.value.map((tile) => tile.value)) >= TILE_HIGHEST_VALUE; - }); - - // Private methods - function _setTileInCell({ cell, tile, isTileToMerge }: _SetTileInCellPayload) { - cell[isTileToMerge ? "tileToMerge" : "tile"] = { - value: tile.value ?? 2, // set default value to 2 - id: tile.id ?? crypto.randomUUID(), - isObstacle: tile.isObstacle, - col: cell.col, - row: cell.row, - }; - } - - async function _moveTiles(gridCellsMatrix: Cell[][]) { - return Promise.all( - gridCellsMatrix.flatMap((group) => { - const promises: Promise[] = []; - - // Starting from the second row since the first row cannot move - for (let i = 1; i < group.length; i++) { - const currentCell = group[i]; - - if (!currentCell.tile || currentCell.tile.isObstacle) continue; - - let lastAvailableCell: Cell | null = null; - - for (let j = i - 1; j >= 0; j--) { - const targetCell = group[j]; - - if (!canCellAcceptTile(targetCell, currentCell.tile)) break; - - lastAvailableCell = targetCell; - } - - if (lastAvailableCell) { - const tileElem = getTileElemById(currentCell.tile.id); - - if (tileElem) { - promises.push( - new Promise((resolve) => { - tileElem.addEventListener("transitionend", () => resolve(), { once: true }); - }), - ); - } - - _setTileInCell({ - cell: lastAvailableCell, - tile: currentCell.tile, - isTileToMerge: !!lastAvailableCell.tile, - }); - - delete currentCell.tile; - - const tileIds = [lastAvailableCell.tile?.id, lastAvailableCell.tileToMerge?.id]; - - renderedTiles.value.forEach((tile) => { - if (tileIds.includes(tile.id)) { - tile.col = lastAvailableCell.col; - tile.row = lastAvailableCell.row; - } - }); - } - } - - return promises; - }), - ); - } - - function getTileElemById(id: string) { - return document.querySelector(`[data-tile-id="${id}"]`); - } - - function canCellAcceptTile(cell: Cell, tile?: Tile) { - return ( - !cell.tile || (!cell.tile.isObstacle && !cell.tileToMerge && cell.tile.value === tile?.value) - ); - } - - function canTileSlide(gridCellsMatrix: Cell[][]) { - return gridCellsMatrix.some((column) => - column.some((cell, cellIndex) => { - const targetCell = column[cellIndex - 1]; - const restrictedCell = !cellIndex || !cell.tile || cell.tile.isObstacle; - - return restrictedCell ? false : canCellAcceptTile(targetCell, cell.tile); - }), - ); - } - - function setRenderedTiles(tiles: Tile[]) { - renderedTiles.value = tiles; - } - - function mergeTilesInGridCells(gridCells: Cell[]) { - gridCells - .filter((cell) => cell.tileToMerge) - .forEach((cell) => { - if (cell.tile) { - cell.tile.value *= 2; - gameStateStore.setScore(gameStateStore.score + cell.tile.value); - } - - delete cell.tileToMerge; - }); - } - - async function moveTilesIfPossible(gridCellsMatrix: Cell[][]) { - if (!canTileSlide(gridCellsMatrix)) { - return false; - } - - await _moveTiles(gridCellsMatrix); - return true; - } - - function addTileToCell({ isObstacle }: { isObstacle?: boolean } = {}) { - const cell = getRandomEmptyGridCell(); - - _setTileInCell({ cell, tile: { value: isObstacle ? 0 : 2, isObstacle } }); - setRenderedTiles([...renderedTiles.value, cell.tile!]); - - return cell; - } - - return { - renderedTiles, - hasReachedHighestValue, - addTileToCell, - canCellAcceptTile, - canTileSlide, - moveTilesIfPossible, - setRenderedTiles, - mergeTilesInGridCells, - getTileElemById, - }; -} diff --git a/src/composables/useUserInput.ts b/src/composables/useUserInput.ts index 48aa23f..3f84af4 100644 --- a/src/composables/useUserInput.ts +++ b/src/composables/useUserInput.ts @@ -1,26 +1,25 @@ import { nextTick } from "vue"; import { storeToRefs } from "pinia"; -import { useGridCells } from "@/composables/useGridCells"; -import { useTiles } from "@/composables/useTiles"; +import { useGridCellsStore } from "@/stores/gridCells"; +import { useTilesStore } from "@/stores/tiles"; import { useGameStateStore } from "@/stores/gameState"; import type { Tile } from "@/types"; export function useUserInput() { - const gameStateStore = useGameStateStore(); - const { gridCells, gridCellsByDirection } = useGridCells(); + const { canAcceptUserInput } = storeToRefs(useGameStateStore()); + const { endGame, setCanAcceptUserInput } = useGameStateStore(); + const { gridCells, gridCellsByDirection } = storeToRefs(useGridCellsStore()); + const { renderedTiles } = storeToRefs(useTilesStore()); const { - renderedTiles, addTileToCell, canTileSlide, moveTilesIfPossible, setRenderedTiles, mergeTilesInGridCells, getTileElemById, - } = useTiles(); - const { canAcceptUserInput } = storeToRefs(gameStateStore); - const { endGame, setCanAcceptUserInput } = gameStateStore; + } = useTilesStore(); async function handleUserInput(event: KeyboardEvent) { if (!canAcceptUserInput.value) return; diff --git a/src/stores/gameState.test.ts b/src/stores/gameState.test.ts index 61bca59..eea0b48 100644 --- a/src/stores/gameState.test.ts +++ b/src/stores/gameState.test.ts @@ -1,4 +1,4 @@ -import { setActivePinia, createPinia } from "pinia"; +import { setActivePinia, createPinia, storeToRefs } from "pinia"; import { useGameStateStore } from "./gameState"; describe("gameState", () => { @@ -9,24 +9,26 @@ describe("gameState", () => { describe("endGame", () => { test("ends game with Win scenario", () => { const { endGame } = useGameStateStore(); + const { gameOverDialog } = storeToRefs(useGameStateStore()); endGame("win"); - expect(useGameStateStore().gameOverDialog.show).toBe(true); - expect(useGameStateStore().gameOverDialog.title).toBe("Congratulations!"); - expect(useGameStateStore().gameOverDialog.message).toBe( + expect(gameOverDialog.value.show).toBe(true); + expect(gameOverDialog.value.title).toBe("Congratulations!"); + expect(gameOverDialog.value.message).toBe( "You've reached the 2048 tile and won the game! Do you want to keep going and aim for an even higher score?", ); }); test("ends game with Lose scenario", () => { const { endGame } = useGameStateStore(); + const { gameOverDialog } = storeToRefs(useGameStateStore()); endGame("lose"); - expect(useGameStateStore().gameOverDialog.show).toBe(true); - expect(useGameStateStore().gameOverDialog.title).toBe("Game Over"); - expect(useGameStateStore().gameOverDialog.message).toBe( + expect(gameOverDialog.value.show).toBe(true); + expect(gameOverDialog.value.title).toBe("Game Over"); + expect(gameOverDialog.value.message).toBe( "You've reached a full board with no possible moves left! Would you like to try again and beat your highest score?", ); }); diff --git a/src/stores/gameState.ts b/src/stores/gameState.ts index 12393ca..cb94c9a 100644 --- a/src/stores/gameState.ts +++ b/src/stores/gameState.ts @@ -23,7 +23,7 @@ const GAME_OVER_DIALOG_CONTENT: Record export const useGameStateStore = defineStore( "gameState", () => { - const gridSize = ref(6); + const gridSize = ref(4); const numObstacles = ref(0); const score = ref(0); const bestScore = ref(0); @@ -88,7 +88,7 @@ export const useGameStateStore = defineStore( { persist: { storage: localStorage, - pick: ["gridSize", "numObstacles", "bestScore"], + pick: ["gridSize", "numObstacles", "bestScore", "score"], }, }, ); diff --git a/src/composables/useGridCells.test.ts b/src/stores/gridCells.test.ts similarity index 70% rename from src/composables/useGridCells.test.ts rename to src/stores/gridCells.test.ts index 6d78397..18a4665 100644 --- a/src/composables/useGridCells.test.ts +++ b/src/stores/gridCells.test.ts @@ -1,9 +1,14 @@ -import { useGridCells } from "./useGridCells"; +import { createPinia, setActivePinia, storeToRefs } from "pinia"; +import { useGridCellsStore } from "./gridCells"; + +describe("useGridCellsStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); -describe("useGridCells", () => { describe("gridCells", () => { test("should generate empty grid cells", () => { - const { gridCells } = useGridCells(); + const { gridCells } = storeToRefs(useGridCellsStore()); expect(gridCells.value).toHaveLength(0); }); @@ -11,18 +16,18 @@ describe("useGridCells", () => { describe("gridCellsByDirection", () => { beforeEach(() => { - const { resetGridCells } = useGridCells(); + const { resetGridCells } = useGridCellsStore(); resetGridCells(2); }); test("should generate grid cells by direction", () => { - const { gridCellsByDirection } = useGridCells(); + const { gridCellsByDirection } = storeToRefs(useGridCellsStore()); expect(Object.keys(gridCellsByDirection.value)).toHaveLength(4); }); test("should have correct values order for ArrowLeft", () => { - const { gridCellsByDirection } = useGridCells(); + const { gridCellsByDirection } = storeToRefs(useGridCellsStore()); expect(gridCellsByDirection.value.ArrowLeft).toEqual([ [ @@ -37,7 +42,7 @@ describe("useGridCells", () => { }); test("should have correct values order for ArrowRight", () => { - const { gridCellsByDirection } = useGridCells(); + const { gridCellsByDirection } = storeToRefs(useGridCellsStore()); expect(gridCellsByDirection.value.ArrowRight).toEqual([ [ @@ -52,7 +57,7 @@ describe("useGridCells", () => { }); test("should have correct values order for ArrowUp", () => { - const { gridCellsByDirection } = useGridCells(); + const { gridCellsByDirection } = storeToRefs(useGridCellsStore()); expect(gridCellsByDirection.value.ArrowUp).toEqual([ [ @@ -67,7 +72,7 @@ describe("useGridCells", () => { }); test("should have correct values order for ArrowDown", () => { - const { gridCellsByDirection } = useGridCells(); + const { gridCellsByDirection } = storeToRefs(useGridCellsStore()); expect(gridCellsByDirection.value.ArrowDown).toEqual([ [ @@ -84,7 +89,9 @@ describe("useGridCells", () => { describe("getRandomEmptyGridCell", () => { test("should return random empty grid cell", () => { - const { getRandomEmptyGridCell, gridCells, resetGridCells } = useGridCells(); + const { gridCells } = storeToRefs(useGridCellsStore()); + const { getRandomEmptyGridCell, resetGridCells } = useGridCellsStore(); + resetGridCells(2); const cell = getRandomEmptyGridCell(); @@ -97,7 +104,7 @@ describe("useGridCells", () => { describe("getTilesFromGridCells", () => { test("should return tiles from grid cells", () => { - const { getTilesFromGridCells, resetGridCells } = useGridCells(); + const { getTilesFromGridCells, resetGridCells } = useGridCellsStore(); resetGridCells(2); const tiles = getTilesFromGridCells(); @@ -108,7 +115,8 @@ describe("useGridCells", () => { describe("resetGridCells", () => { test("should reset grid cells", () => { - const { resetGridCells, gridCells } = useGridCells(); + const { gridCells } = storeToRefs(useGridCellsStore()); + const { resetGridCells } = useGridCellsStore(); resetGridCells(2); @@ -116,7 +124,8 @@ describe("useGridCells", () => { }); test("should overwrite grid cells", () => { - const { resetGridCells, gridCells } = useGridCells(); + const { gridCells } = storeToRefs(useGridCellsStore()); + const { resetGridCells } = useGridCellsStore(); resetGridCells(2); resetGridCells(3); diff --git a/src/stores/gridCells.ts b/src/stores/gridCells.ts new file mode 100644 index 0000000..634b412 --- /dev/null +++ b/src/stores/gridCells.ts @@ -0,0 +1,75 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +import { generateNumArray } from "@/utils"; +import type { Cell, Tile } from "@/types"; + +export const useGridCellsStore = defineStore( + "gridCells", + () => { + const gridCells = ref([]); + + const _gridCellsByColumn = computed(() => { + return gridCells.value.reduce((acc, cell) => { + acc[cell.col - 1] = acc[cell.col - 1] || []; + acc[cell.col - 1][cell.row - 1] = cell; + + return acc; + }, []); + }); + + const _gridCellsByRow = computed(() => { + return gridCells.value.reduce((acc, cell) => { + acc[cell.row - 1] = acc[cell.row - 1] || []; + acc[cell.row - 1][cell.col - 1] = cell; + + return acc; + }, []); + }); + + const gridCellsByDirection = computed>(() => ({ + ArrowLeft: _gridCellsByRow.value, + ArrowRight: _gridCellsByRow.value.map((row) => [...row].reverse()), + ArrowUp: _gridCellsByColumn.value, + ArrowDown: _gridCellsByColumn.value.map((col) => [...col].reverse()), + })); + + function getRandomEmptyGridCell(): Cell { + const emptyCells = gridCells.value.filter((cell) => !cell.tile); + const randomIndex = Math.floor(Math.random() * emptyCells.length); + + return emptyCells[randomIndex]; + } + + function resetGridCells(size: number) { + gridCells.value = generateNumArray(Math.pow(size, 2)).map((index) => ({ + col: (index % size) + 1, + row: Math.floor(index / size) + 1, + })); + } + + function getTilesFromGridCells() { + return gridCells.value.reduce((acc, cell) => { + if (cell.tile) { + acc.push(cell.tile); + } + + return acc; + }, []); + } + + return { + gridCells, + gridCellsByDirection, + getRandomEmptyGridCell, + getTilesFromGridCells, + resetGridCells, + }; + }, + { + persist: { + storage: localStorage, + pick: ["gridCells"], + }, + }, +); diff --git a/src/composables/useTiles.test.ts b/src/stores/tiles.test.ts similarity index 78% rename from src/composables/useTiles.test.ts rename to src/stores/tiles.test.ts index 2305be6..b089931 100644 --- a/src/composables/useTiles.test.ts +++ b/src/stores/tiles.test.ts @@ -1,6 +1,6 @@ -import { setActivePinia, createPinia } from "pinia"; -import { useGridCells } from "./useGridCells"; -import { useTiles } from "./useTiles"; +import { setActivePinia, createPinia, storeToRefs } from "pinia"; +import { useGridCellsStore } from "./gridCells"; +import { useTilesStore } from "./tiles"; const mockCell = { col: 1, row: 1 }; const mockTile = { value: 2, col: 2, row: 1, id: "id-1" }; @@ -13,11 +13,11 @@ vi.mock("@/stores/gameState", () => ({ })), })); -describe("useTiles", () => { +describe("useTilesStore", () => { beforeEach(() => { - const { setRenderedTiles } = useTiles(); - setActivePinia(createPinia()); + + const { setRenderedTiles } = useTilesStore(); setRenderedTiles([]); vi.clearAllMocks(); @@ -25,7 +25,7 @@ describe("useTiles", () => { describe("renderedTiles", () => { test("should be initialized with empty array", () => { - const { renderedTiles } = useTiles(); + const { renderedTiles } = storeToRefs(useTilesStore()); expect(renderedTiles.value).toHaveLength(0); }); @@ -33,7 +33,7 @@ describe("useTiles", () => { describe("getTileElemById", () => { test("should return tile element by id", () => { - const { getTileElemById } = useTiles(); + const { getTileElemById } = useTilesStore(); const tileElem = document.createElement("div"); tileElem.setAttribute("data-tile-id", "test-id"); @@ -46,7 +46,7 @@ describe("useTiles", () => { }); test("should return null if tile element not found", () => { - const { getTileElemById } = useTiles(); + const { getTileElemById } = useTilesStore(); expect(getTileElemById("test-id")).toBeNull(); }); @@ -54,19 +54,19 @@ describe("useTiles", () => { describe("canCellAcceptTile", () => { test("should return true if cell has no tile", () => { - const { canCellAcceptTile } = useTiles(); + const { canCellAcceptTile } = useTilesStore(); expect(canCellAcceptTile(mockCell, mockTile)).toBe(true); }); test("should return false if cell has tile", () => { - const { canCellAcceptTile } = useTiles(); + const { canCellAcceptTile } = useTilesStore(); expect(canCellAcceptTile({ ...mockCell, tile: mockTile })).toBe(false); }); test("should return false if tile in cell is obstacle", () => { - const { canCellAcceptTile } = useTiles(); + const { canCellAcceptTile } = useTilesStore(); expect(canCellAcceptTile({ ...mockCell, tile: { ...mockTile, isObstacle: true } })).toBe( false, @@ -74,7 +74,7 @@ describe("useTiles", () => { }); test("should return false if cell has tile to merge", () => { - const { canCellAcceptTile } = useTiles(); + const { canCellAcceptTile } = useTilesStore(); expect( canCellAcceptTile({ ...mockCell, tile: { ...mockTile }, tileToMerge: mockTile }, mockTile), @@ -82,7 +82,7 @@ describe("useTiles", () => { }); test("should return false if tile in cell has different value", () => { - const { canCellAcceptTile } = useTiles(); + const { canCellAcceptTile } = useTilesStore(); expect(canCellAcceptTile({ ...mockCell, tile: { ...mockTile, value: 3 } }, mockTile)).toBe( false, @@ -90,7 +90,7 @@ describe("useTiles", () => { }); test("should return true if tile in cell has same value", () => { - const { canCellAcceptTile } = useTiles(); + const { canCellAcceptTile } = useTilesStore(); expect(canCellAcceptTile({ ...mockCell, tile: { ...mockTile, value: 2 } }, mockTile)).toBe( true, @@ -100,7 +100,7 @@ describe("useTiles", () => { describe("canTileSlide", () => { test("should return true if tile can slide", () => { - const { canTileSlide } = useTiles(); + const { canTileSlide } = useTilesStore(); const mockCellWithTile = { ...mockCell, tile: mockTile }; expect( @@ -112,7 +112,7 @@ describe("useTiles", () => { }); test("should return false if tile cannot slide", () => { - const { canTileSlide } = useTiles(); + const { canTileSlide } = useTilesStore(); expect(canTileSlide([[mockCell, mockCell], [mockCell]])).toBe(false); }); @@ -120,7 +120,8 @@ describe("useTiles", () => { describe("setRenderedTiles", () => { test("should set rendered tiles", () => { - const { setRenderedTiles, renderedTiles } = useTiles(); + const { renderedTiles } = storeToRefs(useTilesStore()); + const { setRenderedTiles } = useTilesStore(); setRenderedTiles([mockTile]); @@ -128,7 +129,8 @@ describe("useTiles", () => { }); test("should update rendered tiles", () => { - const { setRenderedTiles, renderedTiles } = useTiles(); + const { renderedTiles } = storeToRefs(useTilesStore()); + const { setRenderedTiles } = useTilesStore(); setRenderedTiles([mockTile]); setRenderedTiles([mockTile, mockTile]); // Update rendered tiles @@ -139,7 +141,7 @@ describe("useTiles", () => { describe("mergeTilesInGridCells", () => { test("should merge tiles in grid cells", () => { - const { mergeTilesInGridCells } = useTiles(); + const { mergeTilesInGridCells } = useTilesStore(); const gridCells = [ { ...mockCell, tile: { ...mockTile }, tileToMerge: { ...mockTile } }, @@ -156,7 +158,7 @@ describe("useTiles", () => { }); test("should not merge tiles in grid cells if no tiles to merge", () => { - const { mergeTilesInGridCells } = useTiles(); + const { mergeTilesInGridCells } = useTilesStore(); const gridCells = [{ ...mockCell, tile: { ...mockTile } }]; @@ -169,7 +171,7 @@ describe("useTiles", () => { describe("moveTilesIfPossible", () => { test("should move tiles if possible", async () => { - const { moveTilesIfPossible } = useTiles(); + const { moveTilesIfPossible } = useTilesStore(); const gridCellsMatrix = [ [ @@ -186,7 +188,7 @@ describe("useTiles", () => { }); test("should not move tiles if not possible", async () => { - const { moveTilesIfPossible } = useTiles(); + const { moveTilesIfPossible } = useTilesStore(); const gridCellsMatrix = [ [ @@ -205,19 +207,21 @@ describe("useTiles", () => { describe("addTileToCell", () => { beforeEach(() => { - const { resetGridCells } = useGridCells(); + const { resetGridCells } = useGridCellsStore(); resetGridCells(4); }); test("should add tile to cell", () => { - const { addTileToCell, renderedTiles } = useTiles(); + const { renderedTiles } = storeToRefs(useTilesStore()); + const { addTileToCell } = useTilesStore(); const cell = addTileToCell(); expect(cell.tile).toEqual(renderedTiles.value[0]); }); test("should add obstacle tile to cell", () => { - const { addTileToCell, renderedTiles } = useTiles(); + const { renderedTiles } = storeToRefs(useTilesStore()); + const { addTileToCell } = useTilesStore(); const cell = addTileToCell({ isObstacle: true }); expect(cell.tile).toEqual(renderedTiles.value[0]); diff --git a/src/stores/tiles.ts b/src/stores/tiles.ts new file mode 100644 index 0000000..238db48 --- /dev/null +++ b/src/stores/tiles.ts @@ -0,0 +1,171 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +import { useGameStateStore } from "@/stores/gameState"; +import { useGridCellsStore } from "@/stores/gridCells"; + +import type { Cell, Tile } from "@/types"; + +interface _SetTileInCellPayload { + cell: Cell; + tile: Partial; + isTileToMerge?: boolean; +} + +const TILE_HIGHEST_VALUE = 2048; + +export const useTilesStore = defineStore( + "tiles", + () => { + const renderedTiles = ref([]); + + const hasReachedHighestValue = computed(() => { + return Math.max(...renderedTiles.value.map((tile) => tile.value)) >= TILE_HIGHEST_VALUE; + }); + + // Private methods + function _setTileInCell({ cell, tile, isTileToMerge }: _SetTileInCellPayload) { + cell[isTileToMerge ? "tileToMerge" : "tile"] = { + value: tile.value ?? 2, // set default value to 2 + id: tile.id ?? crypto.randomUUID(), + isObstacle: tile.isObstacle, + col: cell.col, + row: cell.row, + }; + } + + async function _moveTiles(gridCellsMatrix: Cell[][]) { + return Promise.all( + gridCellsMatrix.flatMap((group) => { + const promises: Promise[] = []; + + // Starting from the second row since the first row cannot move + for (let i = 1; i < group.length; i++) { + const currentCell = group[i]; + + if (!currentCell.tile || currentCell.tile.isObstacle) continue; + + let lastAvailableCell: Cell | null = null; + + for (let j = i - 1; j >= 0; j--) { + const targetCell = group[j]; + + if (!canCellAcceptTile(targetCell, currentCell.tile)) break; + + lastAvailableCell = targetCell; + } + + if (lastAvailableCell) { + const tileElem = getTileElemById(currentCell.tile.id); + + if (tileElem) { + promises.push( + new Promise((resolve) => { + tileElem.addEventListener("transitionend", () => resolve(), { once: true }); + }), + ); + } + + _setTileInCell({ + cell: lastAvailableCell, + tile: currentCell.tile, + isTileToMerge: !!lastAvailableCell.tile, + }); + + delete currentCell.tile; + + const tileIds = [lastAvailableCell.tile?.id, lastAvailableCell.tileToMerge?.id]; + + renderedTiles.value.forEach((tile) => { + if (tileIds.includes(tile.id)) { + tile.col = lastAvailableCell.col; + tile.row = lastAvailableCell.row; + } + }); + } + } + + return promises; + }), + ); + } + + function getTileElemById(id: string) { + return document.querySelector(`[data-tile-id="${id}"]`); + } + + function canCellAcceptTile(cell: Cell, tile?: Tile) { + return ( + !cell.tile || + (!cell.tile.isObstacle && !cell.tileToMerge && cell.tile.value === tile?.value) + ); + } + + function canTileSlide(gridCellsMatrix: Cell[][]) { + return gridCellsMatrix.some((column) => + column.some((cell, cellIndex) => { + const targetCell = column[cellIndex - 1]; + const restrictedCell = !cellIndex || !cell.tile || cell.tile.isObstacle; + + return restrictedCell ? false : canCellAcceptTile(targetCell, cell.tile); + }), + ); + } + + function setRenderedTiles(tiles: Tile[]) { + renderedTiles.value = tiles; + } + + function mergeTilesInGridCells(gridCells: Cell[]) { + const gameStateStore = useGameStateStore(); + + gridCells + .filter((cell) => cell.tileToMerge) + .forEach((cell) => { + if (cell.tile) { + cell.tile.value *= 2; + gameStateStore.setScore(gameStateStore.score + cell.tile.value); + } + + delete cell.tileToMerge; + }); + } + + async function moveTilesIfPossible(gridCellsMatrix: Cell[][]) { + if (!canTileSlide(gridCellsMatrix)) { + return false; + } + + await _moveTiles(gridCellsMatrix); + return true; + } + + function addTileToCell({ isObstacle }: { isObstacle?: boolean } = {}) { + const gridCellsStore = useGridCellsStore(); + const cell = gridCellsStore.getRandomEmptyGridCell(); + + _setTileInCell({ cell, tile: { value: isObstacle ? 0 : 2, isObstacle } }); + setRenderedTiles([...renderedTiles.value, cell.tile!]); + + return cell; + } + + return { + renderedTiles, + hasReachedHighestValue, + addTileToCell, + canCellAcceptTile, + canTileSlide, + moveTilesIfPossible, + setRenderedTiles, + mergeTilesInGridCells, + getTileElemById, + }; + }, + { + persist: { + storage: localStorage, + pick: ["renderedTiles"], + }, + }, +); diff --git a/src/utils/generateNumArray.test.ts b/src/utils/generateNumArray.test.ts new file mode 100644 index 0000000..b50eedb --- /dev/null +++ b/src/utils/generateNumArray.test.ts @@ -0,0 +1,17 @@ +import { generateNumArray } from "./generateNumArray"; + +describe("generateNumArray", () => { + test("should generate an array of numbers from 0 to length - 1", () => { + const length = 5; + const result = generateNumArray(length); + + expect(result).toEqual([0, 1, 2, 3, 4]); + }); + + test("should generate an empty array if length is 0", () => { + const length = 0; + const result = generateNumArray(length); + + expect(result).toEqual([]); + }); +});