diff --git a/client/src/ui/components/GameBoardTutorial.tsx b/client/src/ui/components/GameBoardTutorial.tsx new file mode 100644 index 00000000..bc456e26 --- /dev/null +++ b/client/src/ui/components/GameBoardTutorial.tsx @@ -0,0 +1,281 @@ +import React, { useState, useCallback, useEffect, useMemo } from "react"; +import { Card } from "@/ui/elements/card"; +import { useDojo } from "@/dojo/useDojo"; +import { GameBonus } from "../containers/GameBonus"; +import { useMediaQuery } from "react-responsive"; +import { Account } from "starknet"; +import Grid from "./Grid"; +import { transformDataContractIntoBlock } from "@/utils/gridUtils"; +import NextLine from "./NextLine"; +import { Block } from "@/types/types"; +import GameScores from "./GameScores"; +import { Bonus, BonusType } from "@/dojo/game/types/bonus"; +import BonusAnimation from "./BonusAnimation"; +import TournamentTimer from "./TournamentTimer"; +import { ModeType } from "@/dojo/game/types/mode"; +import useTournament from "@/hooks/useTournament"; +import { Game } from "@/dojo/game/models/game"; +import useRank from "@/hooks/useRank"; + +import "../../grid.css"; + +interface GameBoardProps { + initialGrid: number[][]; + nextLine: number[]; + score: number; + combo: number; + maxCombo: number; + hammerCount: number; + waveCount: number; + totemCount: number; + tutorialProps?: { + step: number; + targetBlock: { x: number; y: number; type: string } | null; + isIntermission: boolean; + }; + onBlockSelect?: (block: Block) => void; +} + +const GameBoardTutorial: React.FC = ({ + initialGrid, + nextLine, + score, + combo, + maxCombo, + waveCount, + hammerCount, + totemCount, + tutorialProps, + onBlockSelect, +}) => { + const { + setup: { + systemCalls: { applyBonus }, + }, + } = useDojo(); + + const isMdOrLarger = useMediaQuery({ query: "(min-width: 768px)" }); + const ROWS = 10; + const COLS = 8; + const GRID_SIZE = isMdOrLarger ? 50 : 40; + + const [isTxProcessing, setIsTxProcessing] = useState(false); + // State that will allow us to hide or display the next line + const [nextLineHasBeenConsumed, setNextLineHasBeenConsumed] = useState(false); + // Optimistic data (score, combo, maxcombo) + const [optimisticScore, setOptimisticScore] = useState(score); + const [optimisticCombo, setOptimisticCombo] = useState(combo); + const [optimisticMaxCombo, setOptimisticMaxCombo] = useState(maxCombo); + const [bonusDescription, setBonusDescription] = useState(""); + + useEffect(() => { + // Every time the initial grid changes, we erase the optimistic data + // and set the data to the one returned by the contract + // just in case of discrepancies + setOptimisticScore(score); + setOptimisticCombo(combo); + setOptimisticMaxCombo(maxCombo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialGrid]); + const [bonus, setBonus] = useState(BonusType.None); + + const handleBonusWaveClick = () => { + if (waveCount === 0) return; + if (bonus === BonusType.Wave) { + setBonus(BonusType.None); + setBonusDescription(""); + } else { + setBonus(BonusType.Wave); + setBonusDescription("Select the line you want to destroy"); + } + }; + + const handleBonusTikiClick = () => { + if (totemCount === 0) return; + if (bonus === BonusType.Totem) { + setBonus(BonusType.None); + setBonusDescription(""); + } else { + setBonus(BonusType.Totem); + setBonusDescription("Select the block type you want to destroy"); + } + }; + + const handleBonusHammerClick = () => { + if (hammerCount === 0) return; + if (bonus === BonusType.Hammer) { + setBonus(BonusType.None); + setBonusDescription(""); + } else { + setBonus(BonusType.Hammer); + setBonusDescription("Select the block you want to destroy"); + } + }; + + const handleBonusWaveTx = useCallback(async (rowIndex: number) => { + setIsTxProcessing(true); + try { + // await applyBonus({ + // account: account as Account, + // bonus: new Bonus(BonusType.Wave).into(), + // row_index: ROWS - rowIndex - 1, + // block_index: 0, + // }); + } finally { + //setIsLoading(false); + } + }, []); + + const handleBonusHammerTx = useCallback( + async (rowIndex: number, colIndex: number) => { + setIsTxProcessing(true); + try { + // await applyBonus({ + // account: account as Account, + // bonus: new Bonus(BonusType.Hammer).into(), + // row_index: ROWS - rowIndex - 1, + // block_index: colIndex, + // }); + } finally { + //setIsLoading(false); + } + }, + [], + ); + + const handleBonusTikiTx = useCallback( + async (rowIndex: number, colIndex: number) => { + setIsTxProcessing(true); + try { + // await applyBonus({ + // account: account as Account, + // bonus: new Bonus(BonusType.Totem).into(), + // row_index: ROWS - rowIndex - 1, + // block_index: colIndex, + // }); + } finally { + //setIsLoading(false); + } + }, + [], + ); + + const selectBlock = useCallback( + async (block: Block) => { + if (onBlockSelect) { + await onBlockSelect(block); + return; + } + + if (bonus === BonusType.Wave) { + handleBonusWaveTx(block.y); + } else if (bonus === BonusType.Totem) { + handleBonusTikiTx(block.y, block.x); + } else if (bonus === BonusType.Hammer) { + handleBonusHammerTx(block.y, block.x); + } + }, + [bonus, handleBonusWaveTx, handleBonusTikiTx, handleBonusHammerTx], + ); + + useEffect(() => { + // Reset the isTxProcessing state and the bonus state when the grid changes + // meaning the tx as been processed, and the client state updated + setBonus(BonusType.None); + setBonusDescription(""); + }, [initialGrid]); + + const memoizedInitialData = useMemo(() => { + return transformDataContractIntoBlock(initialGrid); + }, [initialGrid]); + + const memoizedNextLineData = useMemo(() => { + return transformDataContractIntoBlock([nextLine]); + // initialGrid on purpose + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialGrid]); + + if (memoizedInitialData.length === 0) return null; // otherwise sometimes + // the grid is not displayed in Grid because the data is not ready + + return ( + <> + + +
+
+ +
+ +
+ +
+ +
+ +
+
+ {bonus !== BonusType.None && ( +

20 ? "text-sm" : "text-2xl" + } md:text-lg bg-black bg-opacity-50 whitespace-nowrap overflow-hidden text-ellipsis`} + > + {bonusDescription} +

+ )} +
+ +
+
+ + ); +}; + +export default GameBoardTutorial; diff --git a/client/src/ui/components/Tutorial.tsx b/client/src/ui/components/Tutorial.tsx new file mode 100644 index 00000000..599a5c70 --- /dev/null +++ b/client/src/ui/components/Tutorial.tsx @@ -0,0 +1,151 @@ +import React, { useState, useCallback, useMemo } from "react"; +import GameBoard from "./GameBoard"; +import { BonusType } from "@/dojo/game/types/bonus"; +import { ModeType } from "@/dojo/game/types/mode"; +import GameBoardTutorial from "./GameBoardTutorial"; + +interface TutorialProps { + showGrid: boolean; + endTutorial: () => void; +} + +const tutorialInitialState = { + hammerCount: 3, + waveCount: 2, + totemCount: 2, + score: 0, + combo: 0, + maxCombo: 0, + initialGrid: [ + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 2, 0, 0, 0, 0], + [0, 1, 2, 3, 0], + [2, 0, 0, 3, 2], + [1, 0, 2, 2, 0, 0, 1, 1], + [3, 0, 0, 1, 0, 0, 2], + ], + nextLine: [0, 0, 0, 0], +}; + +const Tutorial: React.FC = ({ showGrid, endTutorial }) => { + const [tutorialStep, setTutorialStep] = useState(1); + const [isIntermission, setIsIntermission] = useState(false); + const [state, setState] = useState(tutorialInitialState); + + const handleBlockSelect = useCallback( + async (block: any) => { + // Logique spécifique au tutoriel selon l'étape + switch (tutorialStep) { + case 1: + if (block.y === 8 && block.x === 2) { + setState((prev) => ({ ...prev, score: prev.score + 100 })); + setIsIntermission(true); + } + break; + case 2: + if (block.y === 9 && block.x === 6) { + setState((prev) => ({ + ...prev, + score: prev.score + 25, + hammerCount: prev.hammerCount - 1, + })); + setIsIntermission(true); + } + break; + // ... autres étapes + } + }, + [tutorialStep], + ); + + const tutorialTargetBlock = useMemo(() => { + switch (tutorialStep) { + case 1: + return { x: 2, y: 8, type: "block" }; + case 2: + return { x: 6, y: 9, type: "block" }; + case 3: + return { x: 0, y: 8, type: "row" }; + case 4: + return { x: 0, y: 9, type: "block" }; + default: + return null; + } + }, [tutorialStep]); + + const handleContinue = () => { + if (tutorialStep >= 5) return; + setIsIntermission(false); + setTutorialStep((prev) => prev + 1); + }; + + const TutorialMessage = () => { + switch (tutorialStep) { + case 1: + return "Step 1: Move the highlighted block two steps to the right."; + case 2: + return "Step 2: Use the hammer bonus on the highlighted block."; + case 3: + return "Step 3: Use the wave bonus on the highlighted row."; + case 4: + return "Step 4: Use the totem bonus on the highlighted block."; + case 5: + return "Tutorial complete! Click below to start playing."; + default: + return ""; + } + }; + + if (!showGrid) return null; + + return ( +
+ {isIntermission && ( +
+

Congratulations!

+

+ You have successfully completed Step {tutorialStep}. +

+ +
+ )} + +
+

+ +

+
+ + {tutorialStep === 5 && ( + + )} + + +
+ ); +}; + +export default Tutorial; diff --git a/client/src/ui/containers/Header.tsx b/client/src/ui/containers/Header.tsx index 71ec3d89..6cc26d79 100644 --- a/client/src/ui/containers/Header.tsx +++ b/client/src/ui/containers/Header.tsx @@ -3,17 +3,21 @@ import { useMediaQuery } from "react-responsive"; import DesktopHeader from "../components/DesktopHeader"; import MobileHeader from "../components/MobileHeader"; -export const Header = () => { +interface HeaderProps { + onStartTutorial: () => void; +} + +export const Header: React.FC = ({ onStartTutorial }) => { const isMdOrLarger = useMediaQuery({ query: "(min-width: 768px)" }); return isMdOrLarger ? (
- +
) : (
- +
); diff --git a/client/src/ui/screens/Home.tsx b/client/src/ui/screens/Home.tsx index 3decf0b3..bc8ca40a 100644 --- a/client/src/ui/screens/Home.tsx +++ b/client/src/ui/screens/Home.tsx @@ -40,6 +40,7 @@ import useViewport from "@/hooks/useViewport"; import { TweetPreview } from "../components/TweetPreview"; import { Schema } from "@dojoengine/recs"; import { useGrid } from "@/hooks/useGrid"; +import Tutorial from "../components/Tutorial"; export const Home = () => { const { @@ -79,6 +80,49 @@ export const Home = () => { // State variables for modals const [isTournamentsOpen, setIsTournamentsOpen] = useState(false); + // Tutorial state and handlers + const [tutorialState, setTutorialState] = useState({ + isActive: false, + showGrid: false, + showText: true, + }); + + const handleTutorialCleanup = useCallback(() => { + setTutorialState({ + isActive: false, + showGrid: false, + showText: false, + }); + }, []); + + const startTutorial = useCallback(() => { + try { + setTutorialState((prev) => ({ + ...prev, + isActive: true, + })); + } catch (error) { + console.error("Failed to start tutorial:", error); + handleTutorialCleanup(); + } + }, [handleTutorialCleanup]); + + const handleStartTutorial = useCallback(() => { + setTutorialState({ + isActive: true, + showGrid: true, + showText: false, + }); + startTutorial(); + }, [startTutorial]); + + const endTutorial = useCallback(() => { + setTutorialState((prev) => ({ + ...prev, + isActive: false, + })); + }, []); + const composeTweet = useCallback(() => { setLevel(player?.points ? Level.fromPoints(player?.points).value : ""); setScore(game?.score); @@ -222,7 +266,7 @@ export const Home = () => { return (
-
+
{/* Content Area */}
@@ -255,113 +299,124 @@ export const Home = () => { >
- {!isSigning && } - {(!game || (!!game && isGameOn === "isOver")) && ( - <> - {isMdOrLarger - ? renderDesktopView() - : isTournamentsOpen - ? renderTournamentsView() - : renderMobileView()} - - )} - {game && ( - setIsGameOverOpen(false)} - game={game} + {tutorialState.isActive ? ( + - )} - {!!game && isGameOn === "isOver" && !isTournamentsOpen && ( + ) : ( <> -
-
-

Game Over

- -
-
- {game.score} - -
-
- {game.combo} - -
-
- {game.max_combo} - + {!isSigning && } + {(!game || (!!game && isGameOn === "isOver")) && ( + <> + {isMdOrLarger + ? renderDesktopView() + : isTournamentsOpen + ? renderTournamentsView() + : renderMobileView()} + + )} + {game && ( + setIsGameOverOpen(false)} + game={game} + /> + )} + {!!game && isGameOn === "isOver" && !isTournamentsOpen && ( + <> +
+
+

+ Game Over +

+ +
+
+ {game.score} + +
+
+ {game.combo} + +
+
+ {game.max_combo} + +
+
-
-
- - {!isTournamentsOpen && ( - - - - - - + + + + + + + Feedback + +
+ +
+
+
+ )} + + )} + {!!game && isGameOn === "isOn" && ( +
+
- - Feedback - -
- + +
+ {isMdOrLarger && ( +
+
- - - )} - - )} - {!!game && isGameOn === "isOn" && ( -
-
- -
- {isMdOrLarger && ( -
- + )}
)} -
+ )}