Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/App/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MotionConfig } from 'motion/react';
import Game from '../Game';
import styles from './App.module.css';
import GameResetController from '../GameResetController';

function App() {
return (
Expand All @@ -10,7 +10,7 @@ function App() {
<p>Match pieces to get the longest streak</p>
</header>
<main className="main container">
<Game />
<GameResetController />
</main>
<footer className={styles.footer}>
<div className={`container ${styles.footerContainer}`}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Cell/Cell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
border: var(--border-width) solid var(--border-color);
}

.cell[data-status='empty'] & {
.cell[data-status='all_match'] & {
border: var(--border-width) dotted var(--border-color);
}

Expand Down
81 changes: 15 additions & 66 deletions src/components/Cell/Cell.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,30 @@
import { useEffect, useState } from 'react';
import { CellStatus, GameCell } from '@/globals';
import { getMatches } from '@/helpers/game.helpers';
import { CellStatus } from '@/globals';

import { AnimatePresence, motion } from 'motion/react';
import VisuallyHidden from '../VisuallyHidden';
import styles from './Cell.module.css';
import { staggeredScaleRotate } from './animations';

type CellProps = {
id: string;
disabled: boolean;
status: CellStatus;
pieces: string[];
previous: GameCell | undefined;
updateCellsState: (id: string, matches?: string[]) => void;
onClick: (id: string) => void;
};

function Cell({
id,
disabled,
status,
pieces,
previous,
updateCellsState,
}: CellProps) {
const [showNoMatch, setShowNoMatch] = useState(false);

useEffect(() => {
if (setShowNoMatch) {
const timerId = setTimeout(() => {
setShowNoMatch(false);
}, 2500);
return () => clearTimeout(timerId);
}
}, [showNoMatch]);

function handleClick() {
// do nothing if same cell is clicked twice OR game is over
if (status === 'active' || disabled) return;

// only one cell has been clicked, comparison can't be run
if (!previous) {
updateCellsState(id);
return;
}

const matches = getMatches(previous.pieces, pieces);
if (matches.length === 0) {
setShowNoMatch(true);
}

updateCellsState(id, matches);
}
function Cell({ id, status, pieces, onClick }: CellProps) {
const showNoMatch = status === 'no_match';

return (
<div
id={id}
className={styles.cell}
data-status={disabled ? 'default' : status}
>
<div id={id} className={styles.cell} data-status={status}>
<div className={styles.message}>{showNoMatch && <p>no match</p>}</div>
<button className={styles.btn} onClick={handleClick}>
<button
className={styles.btn}
onClick={() => {
if(status === 'inactive') return; //do nothing
onClick(id);
}}
>
<VisuallyHidden>
{`Select cell with pieces ${pieces.join(', ')}`}
</VisuallyHidden>
Expand All @@ -69,24 +35,7 @@ function Cell({
<motion.svg
key={pieceId}
data-piece={pieceId}
animate={{
scale: [0, 1.5, 1],
rotate: [180, 360, 360],
transition: {
duration: 2,
delay: i * 0.3,
times: [0, 0.25, 0.5],
},
}}
exit={{
scale: [1, 1.5, 0],
rotate: [0, 0, 0],
transition: {
duration: 1,
delay: i * 0.15,
times: [0, 0.25, 0.5],
},
}}
{...staggeredScaleRotate(i)}
>
<use xlinkHref={`/svg-sprite.svg#${pieceId}`} />
</motion.svg>
Expand Down
22 changes: 22 additions & 0 deletions src/components/Cell/animations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function staggeredScaleRotate(index: number) {
return {
animate: {
scale: [0, 1.5, 1],
rotate: [180, 360, 360],
transition: {
duration: 2,
delay: index * 0.3,
times: [0, 0.25, 0.5],
},
},
exit: {
scale: [1, 1.5, 0],
rotate: [0, 0, 0],
transition: {
duration: 1,
delay: index * 0.15,
times: [0, 0.25, 0.5],
},
},
};
}
181 changes: 67 additions & 114 deletions src/components/Game/Game.tsx
Original file line number Diff line number Diff line change
@@ -1,145 +1,98 @@
import { useEffect, useState } from 'react';
import { produce } from 'immer';
import { GRID_CELLS } from '@/constants';
import { createCells } from '@/helpers/game.helpers';
import { GameStatus, GameCell } from '@/globals';
import { useState } from 'react';

import { GameStatus } from '@/globals';
import Cell from '@components/Cell';
import styles from './Game.module.css';
import Banner from '../Banner';
import Stats from '../Stats';
import { AnimatePresence, motion } from 'motion/react';
import useCellData from '@/hooks/useCellData';
import { useStreak } from '@/hooks/useStreak';
import { useActiveAndMatchCells } from '@/hooks/useActiveAndMatchCells';

const MotionBanner = motion.create(Banner);

// Create new game cells
const initialCells = createCells(GRID_CELLS);

function Game() {
function Game({ onReset }: { onReset: () => void }) {
const [gameStatus, setGameStatus] = useState<GameStatus>('running');
const [noMatchCount, setNoMatchCount] = useState(0);
const [streak, setStreak] = useState<number>(0);
const [longestStreak, setLongestStreak] = useState<number>(0);
const [cells, setCells] = useState<GameCell[]>(initialCells);

useEffect(() => {
const allEmpty = cells.every((cell) => cell.pieces.length === 0);
if (!allEmpty) {
const { longestStreak, streak, resetStreak, incrementStreak } = useStreak();
const { cells, dropMatches, areAllEmpty } = useCellData();

const {
activeCellId,
matchCellId,
matchCellStatus,
activateCell,
markNoMatch,
markAllMatch,
markPartialMatch,
} = useActiveAndMatchCells();

function handleClick(cellId: string) {
if (activeCellId === cellId) {
//do nothing if the same cell is clicked
return;
}

setGameStatus(noMatchCount === 0 ? 'won' : 'complete');
}, [cells, noMatchCount]);
if (activeCellId == null) {
activateCell(cellId);
return;
}

const handleRestart = () => {
const newCells = createCells(GRID_CELLS);
setCells(newCells);
setGameStatus('running');
setNoMatchCount(0);
setStreak(0);
setLongestStreak(0);
};
const nextMatchCellId = cellId;
const dropResult = dropMatches(activeCellId, nextMatchCellId);

const removeMatchingPieces = (
activeCell: GameCell,
nextCell: GameCell,
matches: string[],
) => {
matches.forEach((pieceId) => {
activeCell.pieces = activeCell.pieces.filter((item) => item !== pieceId);
nextCell.pieces = nextCell.pieces.filter((item) => item !== pieceId);
});
};
if (dropResult === 'no_match') {
markNoMatch(nextMatchCellId);
setNoMatchCount(noMatchCount + 1);
resetStreak();
return;
}

const resetCellStatuses = (
prevActiveCell: GameCell | undefined,
activeCell?: GameCell | undefined,
) => {
if (prevActiveCell) {
prevActiveCell.status = 'default';
if (dropResult === 'all_match') {
markAllMatch(nextMatchCellId);
incrementStreak();
return;
}
if (activeCell) {
activeCell.status = 'default';

if (dropResult === 'partial_match') {
markPartialMatch(nextMatchCellId);
incrementStreak();
}
};
}

const updateCellStatuses = (
emptyCell: GameCell | undefined,
activeCell: GameCell | undefined,
nextCell: GameCell,
) => {
if (emptyCell) {
emptyCell.status = 'default';
function getCellStatus(cellId: string) {
if (gameStatus !== 'running') {
return 'inactive';
}
if (activeCell) {
activeCell.status = nextCell.pieces.length > 0 ? 'previous' : 'default';
if (activeCellId === cellId) {
return 'active';
}
nextCell.status = nextCell.pieces.length > 0 ? 'active' : 'empty';
};

const updateCounts = (hasMatches: boolean) => {
if (!hasMatches) {
setNoMatchCount((c) => c + 1);
setStreak(0);
return;
if (matchCellId === cellId && matchCellStatus !== null) {
return matchCellStatus;
}

setLongestStreak((ls) => {
if (ls > streak) {
return ls;
} else {
return ls + 1;
}
});
setStreak((s) => s + 1);
};

const updateCellsState = (id: string, matches?: string[]) => {
if (matches) {
updateCounts(matches.length > 0);
return 'default';
}

if (areAllEmpty() && gameStatus === 'running') {
if (noMatchCount > 0) {
setGameStatus('complete');
} else {
setGameStatus('won');
}

setCells(
produce(cells, (draft) => {
const nextCell = draft.find((item) => item.id === id)!;
const activeCell = draft.find((item) => item.status === 'active');
const prevActiveCell = draft.find((item) => item.status === 'previous');
const emptyCell = draft.find((item) => item.status === 'empty');

// zero matches
if (matches && matches.length === 0) {
resetCellStatuses(prevActiveCell, activeCell);
}

// has matches
if (matches && matches.length > 0) {
removeMatchingPieces(activeCell!, nextCell!, matches);
resetCellStatuses(prevActiveCell);
updateCellStatuses(emptyCell, activeCell, nextCell);
}

// comparison hasn't happened yet
if (typeof matches === 'undefined') {
updateCellStatuses(emptyCell, activeCell, nextCell);
}
}),
);
};
}

return (
<>
<section className={styles.grid}>
{cells.map(({ id, status, pieces }) => {
{Object.keys(cells).map((cellId) => {
return (
<Cell
key={id}
id={id}
status={status}
disabled={gameStatus !== 'running'}
pieces={pieces}
previous={cells.find(
(cell: GameCell) => cell.status === 'active',
)}
updateCellsState={updateCellsState}
id={cellId}
key={cellId}
pieces={cells[cellId]}
status={getCellStatus(cellId)}
onClick={handleClick}
/>
);
})}
Expand All @@ -150,7 +103,7 @@ function Game() {
<MotionBanner
status={gameStatus}
longestStreak={longestStreak}
resetGame={handleRestart}
resetGame={onReset}
/>
)}
</AnimatePresence>
Expand Down
14 changes: 14 additions & 0 deletions src/components/GameResetController/GameResetController.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from 'react';
import Game from '../Game/Game';

function GameResetController() {
const [resetKey, setResetKey] = React.useState(crypto.randomUUID());

function resetGame() {
setResetKey(crypto.randomUUID())
}

return <Game key={resetKey} onReset={resetGame}></Game>;
}

export default GameResetController;
2 changes: 2 additions & 0 deletions src/components/GameResetController/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './GameResetController';
export { default } from './GameResetController';
Loading