diff --git a/client/src/ui/components/DesktopHeader.tsx b/client/src/ui/components/DesktopHeader.tsx index dbaf3e7f..82a78c92 100644 --- a/client/src/ui/components/DesktopHeader.tsx +++ b/client/src/ui/components/DesktopHeader.tsx @@ -11,7 +11,7 @@ import LevelIndicator from "./LevelIndicator"; import SettingsDropDown from "./SettingsDropDown"; import { useNavigate } from "react-router-dom"; import { Controller } from "./Controller"; -import TutorialModal from "./TutorialModal"; +import TutorialModal from "./Tutorial/TutorialModal"; interface DesktopHeaderProps { onStartTutorial: () => void; diff --git a/client/src/ui/components/MobileHeader.tsx b/client/src/ui/components/MobileHeader.tsx index 4d41b607..67aaa1d1 100644 --- a/client/src/ui/components/MobileHeader.tsx +++ b/client/src/ui/components/MobileHeader.tsx @@ -29,7 +29,7 @@ import { useState } from "react"; import { Surrender } from "../actions/Surrender"; import LevelIndicator from "./LevelIndicator"; import { Controller } from "./Controller"; -import TutorialModal from "./TutorialModal"; +import TutorialModal from "./Tutorial/TutorialModal"; interface MobileHeaderProps { onStartTutorial: () => void; diff --git a/client/src/ui/components/GameBoardTutorial.tsx b/client/src/ui/components/Tutorial/GameBoardTutorial.tsx similarity index 81% rename from client/src/ui/components/GameBoardTutorial.tsx rename to client/src/ui/components/Tutorial/GameBoardTutorial.tsx index bc456e26..8ffbbc9b 100644 --- a/client/src/ui/components/GameBoardTutorial.tsx +++ b/client/src/ui/components/Tutorial/GameBoardTutorial.tsx @@ -1,28 +1,21 @@ 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 { 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 NextLine from "../NextLine"; import { Block } from "@/types/types"; -import GameScores from "./GameScores"; +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 BonusAnimation from "../BonusAnimation"; -import "../../grid.css"; +import "../../../grid.css"; +import TutorialGrid from "./TutorialGrid"; interface GameBoardProps { initialGrid: number[][]; nextLine: number[]; - score: number; combo: number; maxCombo: number; hammerCount: number; @@ -30,7 +23,7 @@ interface GameBoardProps { totemCount: number; tutorialProps?: { step: number; - targetBlock: { x: number; y: number; type: string } | null; + targetBlock: { x: number; y: number; type: "block" | "row" } | null; isIntermission: boolean; }; onBlockSelect?: (block: Block) => void; @@ -39,7 +32,6 @@ interface GameBoardProps { const GameBoardTutorial: React.FC = ({ initialGrid, nextLine, - score, combo, maxCombo, waveCount, @@ -48,12 +40,6 @@ const GameBoardTutorial: React.FC = ({ tutorialProps, onBlockSelect, }) => { - const { - setup: { - systemCalls: { applyBonus }, - }, - } = useDojo(); - const isMdOrLarger = useMediaQuery({ query: "(min-width: 768px)" }); const ROWS = 10; const COLS = 8; @@ -63,16 +49,18 @@ const GameBoardTutorial: React.FC = ({ // 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 [optimisticScore, setOptimisticScore] = useState(0); const [optimisticCombo, setOptimisticCombo] = useState(combo); const [optimisticMaxCombo, setOptimisticMaxCombo] = useState(maxCombo); const [bonusDescription, setBonusDescription] = useState(""); + const [score, setScore] = useState(0); + const [isIntermission, setIsIntermission] = useState(false); 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); + setOptimisticScore(0); setOptimisticCombo(combo); setOptimisticMaxCombo(maxCombo); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -112,6 +100,11 @@ const GameBoardTutorial: React.FC = ({ } }; + const updateValue = (intermission: boolean) => { + setScore((score ?? 0) + 15); + setIsIntermission(intermission); + }; + const handleBonusWaveTx = useCallback(async (rowIndex: number) => { setIsTxProcessing(true); try { @@ -185,17 +178,17 @@ const GameBoardTutorial: React.FC = ({ setBonusDescription(""); }, [initialGrid]); - const memoizedInitialData = useMemo(() => { + const memorizedInitialData = useMemo(() => { return transformDataContractIntoBlock(initialGrid); }, [initialGrid]); - const memoizedNextLineData = useMemo(() => { + const memorizedNextLineData = useMemo(() => { return transformDataContractIntoBlock([nextLine]); // initialGrid on purpose // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialGrid]); - if (memoizedInitialData.length === 0) return null; // otherwise sometimes + if (memorizedInitialData.length === 0) return null; // otherwise sometimes // the grid is not displayed in Grid because the data is not ready return ( @@ -205,7 +198,7 @@ const GameBoardTutorial: React.FC = ({ > @@ -224,7 +217,7 @@ const GameBoardTutorial: React.FC = ({ /> = ({
- = ({ setIsTxProcessing={setIsTxProcessing} tutorialHighlight={tutorialProps?.targetBlock} isIntermission={tutorialProps?.isIntermission} + /> */} + { + // Ignore the intermission parameter since we only care about score updates + setOptimisticScore((prev) => (prev ?? 0) + 1); + }} + ref={setBonus} />
@@ -267,7 +278,7 @@ const GameBoardTutorial: React.FC = ({ )} , + block: BlockProps["block"], + ) => void; + handleTouchStart?: ( + e: React.TouchEvent, + block: BlockProps["block"], + ) => void; + onTransitionBlockStart?: () => void; + onTransitionBlockEnd?: () => void; + isHighlighted?: boolean; + highlightType?: "block" | "row"; + isClickable?: boolean; // If the block or row is clickable +} + +const BlockContainer: React.FC = ({ + block, + gridSize, + transitionDuration = 100, + isTxProcessing = false, + state, + handleMouseDown, + handleTouchStart, + onTransitionBlockStart, + onTransitionBlockEnd, + isHighlighted, + highlightType = "block", + isClickable, +}) => { + const ref = useRef(null); + + useEffect(() => { + if (ref.current === null) return; + + const element = ref.current; // Capture the current value + const onTransitionStart = () => { + console.log("Transition started for block", block); + onTransitionBlockStart && onTransitionBlockStart(); + }; + + element.addEventListener("transitionstart", onTransitionStart); + + return () => { + element.removeEventListener("transitionstart", onTransitionStart); + }; + }, [block, onTransitionBlockStart]); + + const handleTransitionEnd = () => { + console.log("Transition ended for block", block); + onTransitionBlockEnd && onTransitionBlockEnd(); + }; + + // Determine the CSS class based on highlight status + const highlightClass = isHighlighted + ? highlightType === "row" + ? "ring-2 ring-yellow-400 ring-opacity-50" // Subtle row highlight + : "ring-4 ring-yellow-400 animate-pulse" // Prominent block highlight + : ""; + + // Only make the block clickable if it's highlighted + const isBlockClickable = isHighlighted && isClickable; + + return ( +
{ + if (isBlockClickable && handleMouseDown) { + handleMouseDown(e, block); // Allow clicking only if it's clickable + } + }} + onTouchStart={(e) => { + if (isBlockClickable && handleTouchStart) { + handleTouchStart(e, block); // Allow touch only if it's clickable + } + }} + onTransitionEnd={handleTransitionEnd} + >
+ ); +}; + +export default BlockContainer; diff --git a/client/src/ui/components/Tutorial/TutorialGrid.tsx b/client/src/ui/components/Tutorial/TutorialGrid.tsx new file mode 100644 index 00000000..a1b81031 --- /dev/null +++ b/client/src/ui/components/Tutorial/TutorialGrid.tsx @@ -0,0 +1,486 @@ +import React, { + useCallback, + useEffect, + useState, + forwardRef, + useImperativeHandle, + useRef, +} from "react"; +import "../../../grid.css"; +import { Account } from "starknet"; +import { GameState } from "@/enums/gameEnums"; +import { Block } from "@/types/types"; +import { + removeCompleteRows, + concatenateAndShiftBlocks, + isGridFull, + removeBlocksSameWidth, + removeBlocksSameRow, + removeBlockId, +} from "@/utils/gridUtils"; +import { MoveType } from "@/enums/moveEnum"; +import AnimatedText from "../../elements/animatedText"; +import { ComboMessages } from "@/enums/comboEnum"; +import { motion } from "framer-motion"; +import { BonusType } from "@/dojo/game/types/bonus"; +import BlockContainer from "./TutorialBlock"; +import ConfettiExplosion, { ConfettiExplosionRef } from "../ConfettiExplosion"; + +interface GridProps { + initialData: Block[]; + nextLineData: Block[]; + gridSize: number; + gridWidth: number; + gridHeight: number; + selectBlock: (block: Block) => void; + onMove?: (rowIndex: number, startIndex: number, finalIndex: number) => void; + bonus: BonusType; + account: Account | null; + tutorialStep: number; + tutorialTargetBlock: { x: number; y: number; type: "block" | "row" } | null; + onUpdate: (intermission: boolean) => void; + ref: any; + intermission?: boolean; +} + +const TutorialGrid: React.FC = forwardRef( + ( + { + initialData, + nextLineData, + gridHeight, + gridWidth, + gridSize, + selectBlock, + onMove, + bonus, + account, + tutorialStep, + tutorialTargetBlock, + onUpdate, + intermission, + }, + ref, + ) => { + const [blocks, setBlocks] = useState(initialData); + const [isMoving, setIsMoving] = useState(true); + const [gameState, setGameState] = useState(GameState.WAITING); + const [transitioningBlocks, setTransitioningBlocks] = useState( + [], + ); + const [lineExplodedCount, setLineExplodedCount] = useState(0); + const [shouldBounce, setShouldBounce] = useState(false); + const [animateText, setAnimateText] = useState( + ComboMessages.None, + ); + const explosionRef = useRef(null); + const gridRef = useRef(null); + const [gridPosition, setGridPosition] = useState(null); + const [dragging, setDragging] = useState(null); + const [dragStartX, setDragStartX] = useState(0); + const [initialX, setInitialX] = useState(0); + const [pendingMove, setPendingMove] = useState<{ + block: Block; + rowIndex: number; + startX: number; + finalX: number; + } | null>(null); + + useEffect(() => { + if (gridRef.current) { + const position = gridRef.current.getBoundingClientRect(); + setGridPosition(position); + } + }, []); + + const handleTransitionBlockStart = (id: number) => { + setTransitioningBlocks((prev) => [...prev, id]); + }; + + const handleTransitionBlockEnd = (id: number) => { + setTransitioningBlocks((prev) => + prev.filter((blockId) => blockId !== id), + ); + }; + + useEffect(() => { + if (initialData.length === 0) return; + setBlocks(initialData); + }, [initialData]); + + useEffect(() => { + const interval = setInterval(() => { + if ( + gameState === GameState.GRAVITY || + gameState === GameState.GRAVITY2 + ) { + applyGravity(); + } + }, 100); + + return () => clearInterval(interval); + }, [gameState]); + + const calculateFallDistance = (block: Block, blocks: Block[]) => { + let maxFall = gridHeight - block.y - 1; + for (let y = block.y + 1; y < gridHeight; y++) { + if (isCollision(block.x, y, block.width, blocks, block.id)) { + maxFall = y - block.y - 1; + break; + } + } + return maxFall; + }; + + const isCollision = ( + x: number, + y: number, + width: number, + blocks: Block[], + blockId: number, + ) => { + return blocks.some( + (block) => + block.id !== blockId && + block.y === y && + x < block.x + block.width && + x + width > block.x, + ); + }; + + const applyGravity = useCallback(() => { + setBlocks((prevBlocks) => { + const newBlocks = prevBlocks.map((block) => { + const fallDistance = calculateFallDistance(block, prevBlocks); + console.log("Calculating fall distance", { + blockId: block.id, + fallDistance, + }); + + if (fallDistance > 0) { + return { ...block, y: block.y + 1 }; + } + return block; + }); + + const hasChanged = newBlocks.some( + (block, i) => block.y !== prevBlocks[i].y, + ); + + if (!hasChanged) { + setIsMoving(false); + } + + return hasChanged ? newBlocks : prevBlocks; + }); + }, [gridHeight]); + + const handleDragStart = (x: number, block: Block) => { + console.log("Drag start:", block); + setDragging(block); + setDragStartX(x); + setInitialX(block.x); + setGameState(GameState.DRAGGING); + }; + + const handleDragMove = (x: number, moveType: MoveType) => { + if (!dragging) return; + + const deltaX = x - dragStartX; + const newX = initialX + deltaX / gridSize; + const boundedX = Math.max(0, Math.min(gridWidth - dragging.width, newX)); + + console.log("Drag move:", { deltaX, newX, boundedX }); + + if ( + !isBlocked( + initialX, + boundedX, + dragging.y, + dragging.width, + blocks, + dragging.id, + ) + ) { + if (boundedX <= 0 || boundedX >= gridWidth - dragging.width) { + if (moveType === MoveType.TOUCH) { + endDrag(); + return; + } else { + setInitialX(blocks.find((b) => b.id === dragging.id)?.x ?? 0); + } + } + setBlocks((prevBlocks) => + prevBlocks.map((b) => + b.id === dragging.id ? { ...b, x: boundedX } : b, + ), + ); + } + }; + + const endDrag = useCallback(() => { + if (!dragging) return; + + console.log("endDrag called", { + dragging, + initialX, + currentX: dragging.x, + }); + + setBlocks((prevBlocks) => { + const updatedBlocks = prevBlocks.map((b) => { + if (b.id === dragging.id) { + const finalX = Math.round(b.x); + console.log("Finalizing drag position", { finalX, initialX }); + + if (Math.trunc(finalX) !== Math.trunc(initialX)) { + setPendingMove({ + block: b, + rowIndex: b.y, + startX: initialX, + finalX, + }); + } + return { ...b, x: finalX }; + } + return b; + }); + return updatedBlocks; + }); + + setDragging(null); + setIsMoving(true); + setGameState(GameState.GRAVITY); + }, [dragging, initialX]); + + const handleMouseDown = (e: React.MouseEvent, block: Block) => { + e.preventDefault(); + e.stopPropagation(); + handleDragStart(e.clientX, block); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + e.preventDefault(); + if (dragging) { + handleDragMove(e.clientX, MoveType.MOUSE); + } + }; + + const handleTouchStart = (e: React.TouchEvent, block: Block) => { + e.preventDefault(); + e.stopPropagation(); + const touch = e.touches[0]; + handleDragStart(touch.clientX, block); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + e.preventDefault(); + if (dragging) { + const touch = e.touches[0]; + handleDragMove(touch.clientX, MoveType.TOUCH); + } + }; + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + endDrag(); + }, + [endDrag], + ); + + useEffect(() => { + if (!dragging) return; + + const handleMouseUp = () => { + endDrag(); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [dragging, endDrag]); + + const isBlocked = ( + initialX: number, + newX: number, + y: number, + width: number, + blocks: Block[], + blockId: number, + ) => { + const rowBlocks = blocks.filter( + (block) => block.y === y && block.id !== blockId, + ); + + if (newX > initialX) { + return rowBlocks.some( + (block) => block.x >= initialX + width && block.x < newX + width, + ); + } else { + return rowBlocks.some( + (block) => block.x + block.width > newX && block.x <= initialX, + ); + } + }; + + const isHighlighted = (block: Block) => { + if (!tutorialTargetBlock) return false; + + if (tutorialTargetBlock.type === "row") { + return block.y === tutorialTargetBlock.y; + } else { + return ( + block.x === tutorialTargetBlock.x && block.y === tutorialTargetBlock.y + ); + } + }; + + const resetAnimateText = (): void => { + setAnimateText(ComboMessages.None); + }; + + const handleTriggerLocalExplosion = (x: number, y: number) => { + if (explosionRef.current) { + explosionRef.current.triggerLocalExplosion({ x, y }); + } + }; + + const handleLineClear = ( + newGravityState: GameState, + newStateOnComplete: GameState, + ) => { + const { updatedBlocks, completeRows } = removeCompleteRows( + blocks, + gridWidth, + gridHeight, + ); + + if (updatedBlocks.length < blocks.length) { + setLineExplodedCount(lineExplodedCount + completeRows.length); + + completeRows.forEach((rowIndex) => { + const blocksSameRow = blocks.filter((block) => block.y === rowIndex); + const gridRect = gridRef.current?.getBoundingClientRect(); + + if (gridRect) { + blocksSameRow.forEach((block) => { + const explosionX = + gridRect.left + + block.x * gridSize + + (block.width * gridSize) / 2; + const explosionY = + gridRect.top + block.y * gridSize + gridSize / 2; + + console.log("Triggering explosion at:", { + explosionX, + explosionY, + }); + handleTriggerLocalExplosion(explosionX, explosionY); + }); + } + }); + + setBlocks(updatedBlocks); + setIsMoving(true); + setGameState(newGravityState); + } else { + setGameState(newStateOnComplete); + } + }; + + useEffect(() => { + if (gameState === GameState.LINE_CLEAR) { + handleLineClear(GameState.GRAVITY, GameState.WAITING); + } else if (gameState === GameState.LINE_CLEAR2) { + handleLineClear(GameState.GRAVITY2, GameState.WAITING); + } + }, [gameState, blocks]); + + useEffect(() => { + if (lineExplodedCount > 0) { + setShouldBounce(true); + setTimeout(() => setShouldBounce(false), 500); + } + }, [lineExplodedCount]); + + useEffect(() => { + if ( + (gameState === GameState.GRAVITY || gameState === GameState.GRAVITY2) && + !isMoving && + transitioningBlocks.length === 0 + ) { + console.log("Transitioning from GRAVITY to LINE_CLEAR", { + gameState, + isMoving, + transitioningBlocks, + }); + setGameState(GameState.LINE_CLEAR); + } + }, [gameState, isMoving, transitioningBlocks]); + + return ( + <> + + +
+
+ {blocks.map((block) => ( + handleMouseDown(e, block)} + handleTouchStart={(e) => handleTouchStart(e, block)} + onTransitionBlockStart={() => + handleTransitionBlockStart(block.id) + } + onTransitionBlockEnd={() => + handleTransitionBlockEnd(block.id) + } + isHighlighted={isHighlighted(block)} + isClickable={!tutorialStep || isHighlighted(block)} + /> + ))} +
+ +
+
+
+
+ + ); + }, +); + +export default TutorialGrid; diff --git a/client/src/ui/components/TutorialModal.tsx b/client/src/ui/components/Tutorial/TutorialModal.tsx similarity index 91% rename from client/src/ui/components/TutorialModal.tsx rename to client/src/ui/components/Tutorial/TutorialModal.tsx index c44e0634..566a4c9c 100644 --- a/client/src/ui/components/TutorialModal.tsx +++ b/client/src/ui/components/Tutorial/TutorialModal.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { Dialog, DialogContent, DialogTitle } from "../elements/dialog"; -import { Button } from "../elements/button"; +import { Dialog, DialogContent, DialogTitle } from "../../elements/dialog"; +import { Button } from "../../elements/button"; import ImageAssets from "@/ui/theme/ImageAssets"; -import { useTheme } from "../elements/theme-provider/hooks"; +import { useTheme } from "../../elements/theme-provider/hooks"; import { X } from "lucide-react"; interface TutorialModalProps { isOpen: boolean; diff --git a/client/src/ui/screens/Home.tsx b/client/src/ui/screens/Home.tsx index bc8ca40a..a35a5e7d 100644 --- a/client/src/ui/screens/Home.tsx +++ b/client/src/ui/screens/Home.tsx @@ -40,7 +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"; +import Tutorial from "../components/Tutorial/Tutorial"; export const Home = () => { const {