diff --git a/package.json b/package.json index 15e6c60..a8347be 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "dotenv": "^16.4.7", "eslint": "^9.23.0", + "framer-motion": "^12.6.2", "next": "^15.2.3", "next-auth": "5.0.0-beta.25", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f703905..40848a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: eslint: specifier: ^9.23.0 version: 9.23.0 + framer-motion: + specifier: ^12.6.2 + version: 12.6.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next: specifier: ^15.2.3 version: 15.2.3(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1826,6 +1829,23 @@ packages: } engines: { node: ">= 0.4" } + framer-motion@12.6.2: + resolution: + { + integrity: sha512-7LgPRlPs5aG8UxeZiMCMZz8firC53+2+9TnWV22tuSi38D3IFRxHRUqOREKckAkt6ztX+Dn6weLcatQilJTMcg==, + } + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + fs-minipass@2.1.0: resolution: { @@ -2543,6 +2563,18 @@ packages: engines: { node: ">=10" } hasBin: true + motion-dom@12.6.1: + resolution: + { + integrity: sha512-8XVsriTUEVOepoIDgE/LDGdg7qaKXWdt+wQA/8z0p8YzJDLYL8gbimZ3YkCLlj7bB2i/4UBD/g+VO7y9ZY0zHQ==, + } + + motion-utils@12.5.0: + resolution: + { + integrity: sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==, + } + ms@2.1.3: resolution: { @@ -4997,6 +5029,15 @@ snapshots: dependencies: is-callable: 1.2.7 + framer-motion@12.6.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + motion-dom: 12.6.1 + motion-utils: 12.5.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + fs-minipass@2.1.0: dependencies: minipass: 3.3.6 @@ -5412,6 +5453,12 @@ snapshots: mkdirp@1.0.4: {} + motion-dom@12.6.1: + dependencies: + motion-utils: 12.5.0 + + motion-utils@12.5.0: {} + ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/src/app/games/(games)/page.tsx b/src/app/games/(games)/page.tsx index 43bcb33..9a79b7a 100644 --- a/src/app/games/(games)/page.tsx +++ b/src/app/games/(games)/page.tsx @@ -51,6 +51,14 @@ export default async function Games() { linktext="Boom Boom Pirate" /> + + +
+ +
+ Cow racing + + +
+ + ); +} diff --git a/src/components/cowrace.tsx b/src/components/cowrace.tsx new file mode 100644 index 0000000..f6f0075 --- /dev/null +++ b/src/components/cowrace.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Button, Slider, Text, Stack, Group } from "@mantine/core"; +import { motion } from "framer-motion"; +import { IconTrophy, IconFlag } from "@tabler/icons-react"; +import playConfetti from "@/components/playconfetti"; + +const CowRace = () => { + const [numCows, setNumCows] = useState(4); // Start with 4 cows + const [raceStarted, setRaceStarted] = useState(false); + const [cowPositions, setCowPositions] = useState([]); + const [winners, setWinners] = useState([]); + const cowRefs = useRef<(HTMLImageElement | null)[]>([]); + const raceTrackRef = useRef(null); + const raceIntervalRef = useRef(null); // Use useRef instead of state + + const startRace = () => { + setRaceStarted(true); + }; + + useEffect(() => { + if (raceStarted && raceTrackRef.current) { + const raceTrackRect = raceTrackRef.current.getBoundingClientRect(); + const finishLine = raceTrackRect.right / 2.38; // Adjust finish line position + + setCowPositions(Array(numCows).fill(0)); + setWinners([]); + cowRefs.current = Array(numCows).fill(null); + + let winnerDetected = false; + + const intervalId = setInterval(() => { + setCowPositions((prevPositions) => { + const newPositions = prevPositions.map( + (pos) => pos + Math.random() * 7, + ); // Adjust cow speed + + const finishedCows = newPositions + .map((pos, index) => ({ pos, index })) + .filter(({ pos }) => pos >= finishLine); + + if (!winnerDetected && finishedCows.length > 0) { + winnerDetected = true; + const sortedWinners = finishedCows.sort((a, b) => a.pos - b.pos); + setWinners(sortedWinners.map(({ index }) => index)); + playConfetti(); + clearInterval(intervalId); + raceIntervalRef.current = null; + } + + return newPositions.map((pos) => Math.min(pos, finishLine)); + }); + }, 100); + + raceIntervalRef.current = intervalId; + } else { + if (raceIntervalRef.current) clearInterval(raceIntervalRef.current); + raceIntervalRef.current = null; + } + }, [raceStarted, numCows]); + + const resetRace = () => { + setRaceStarted(false); + setCowPositions([]); + setWinners([]); + if (raceIntervalRef.current) clearInterval(raceIntervalRef.current); + raceIntervalRef.current = null; + }; + + const cowImageUrl = "/images/cow.svg"; + + return ( + + {!raceStarted ? ( +
+ + Number of racing cows: {numCows} + + `${value} Cows`} + /> + +
+ ) : ( +
+
+ {Array.from({ length: numCows }).map((_, index) => ( + { + cowRefs.current[index] = el; + }} + animate={{ + x: cowPositions[index] || 0, + }} + /> + ))} +
+ + + Finish + +
+
+ + + + + + {winners.length > 0 && ( +
+ + Results: + + + {winners.slice(0, 3).map((winnerIndex, rank) => { + let placeText = ""; + let trophyColor = ""; + switch (rank) { + case 0: + placeText = "1st Place: "; + trophyColor = "#FFD700"; // Gold + break; + case 1: + placeText = "2nd Place: "; + trophyColor = "#C0C0C0"; // Silver + break; + case 2: + placeText = "3rd Place: "; + trophyColor = "#CD7F32"; // Bronze + break; + default: + placeText = `${rank + 1}th Place: `; + trophyColor = "#808080"; + } + return ( +
+ + + {placeText} Cow {winnerIndex + 1} + +
+ ); + })} + {winners.length > 3 && ( + + Other finishers:{" "} + {winners + .slice(3) + .map((w) => `Cow ${w + 1}`) + .join(", ")} + + )} +
+
+ )} +
+ )} +
+ ); +}; + +export default CowRace; diff --git a/tests/public/01_navigation.spec.ts b/tests/public/01_navigation.spec.ts index ce1e906..fa9e3ab 100644 --- a/tests/public/01_navigation.spec.ts +++ b/tests/public/01_navigation.spec.ts @@ -46,6 +46,12 @@ test("Can navigate to the games page and through its subpages", async ({ await expect(page).toHaveURL("/games/boomboompirate"); await expect(page.locator("h1")).toContainText("Boom"); + await page.getByLabel("Back to our games list").click(); + await page.getByRole("link", { name: "Cow racing!" }).click(); + await expect(page).toHaveTitle("Cow racing | Almost Yellow"); + await expect(page).toHaveURL("/games/cowrace"); + await expect(page.locator("h1")).toContainText("Cow"); + await page.getByLabel("Back to our games list").click(); await page.getByRole("link", { name: "Home" }).click(); await expect(page).toHaveURL("/");