diff --git a/cypress/e2e/3-guess-the-guild/0-guess-name.spec.ts b/cypress/e2e/3-guess-the-guild/0-guess-name.spec.ts new file mode 100644 index 000000000..a184e2e30 --- /dev/null +++ b/cypress/e2e/3-guess-the-guild/0-guess-name.spec.ts @@ -0,0 +1,154 @@ +const setGameModeToGuessName = () => { + Cypress.on("window:before:load", (win) => { + Object.defineProperty(win.Math, "random", { + value: function () { + return 0.1 + }, + writable: false, + }) + }) +} + +describe.skip("api fetching returns an error", () => { + it("displays error toast message", () => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 500, + }).as("failedGuildLoad") + + cy.visit("/guess-the-guild") + + cy.wait("@failedGuildLoad") + + cy.get(".chakra-toast", { timeout: 5000 }).should("be.visible") + }) +}) + +describe.skip("in 'guess name' mode", () => { + beforeEach(() => { + cy.visit("/guess-the-guild") + setGameModeToGuessName() + cy.contains("Let's Go!").click() + }) + + it("is not possible to press submit, until an answer is selected", () => { + cy.getByDataTest("submit").should("be.disabled") + }) + + it("is possible to press submit, after an answer is selected", () => { + cy.getByDataTest("answer-button").first().click() + cy.getByDataTest("submit").should("not.be.disabled") + }) + + it("score indicator shows up", () => { + cy.get("#score-indicator").should("be.visible") + }) +}) + +describe.skip("game mode answer submit", () => { + it("shows success message for good answer", () => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 200, + fixture: "testGuilds.json", + }).as("guildsLoaded") + + cy.visit("/guess-the-guild") + setGameModeToGuessName() + + cy.contains("Let's Go!").click() + cy.getByDataTest("answer-button").first().click() + cy.getByDataTest("submit").click() + cy.get('[data-status="success"]').should("be.visible") + }) + + it("shows wrong answer message", () => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 200, + fixture: "testGuilds.json", + }).as("guildsLoaded") + + cy.visit("/guess-the-guild") + setGameModeToGuessName() + + cy.contains("Let's Go!").click() + cy.getByDataTest("answer-button").last().click() + cy.getByDataTest("submit").click() + cy.get('[data-status="warning"]').should("be.visible") + }) + + it("increases score by 1 on good answer", () => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 200, + fixture: "testGuilds.json", + }).as("guildsLoaded") + + cy.visit("/guess-the-guild") + setGameModeToGuessName() + + cy.contains("Let's Go!").click() + + cy.get("span").contains("Score:").parent().contains("0").should("be.visible") + + cy.getByDataTest("answer-button").first().click() + cy.getByDataTest("submit").click() + + cy.contains("1 points").should("be.visible") + }) + + it("does not change score on wrong answer", () => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 200, + fixture: "testGuilds.json", + }).as("guildsLoaded") + + cy.visit("/guess-the-guild") + setGameModeToGuessName() + + cy.contains("Let's Go!").click() + + cy.get("span").contains("Score:").parent().contains("0").should("be.visible") + + cy.getByDataTest("answer-button").last().click() + cy.getByDataTest("submit").click() + + cy.get("span").contains("Score:").parent().contains("0").should("be.visible") + }) +}) + +describe("game screen order", () => { + beforeEach(() => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 200, + fixture: "testGuilds.json", + }).as("guildsLoaded") + + cy.visit("/guess-the-guild") + setGameModeToGuessName() + + cy.contains("Let's Go!").click() + }) + + it.skip("game continues on good answer", () => { + cy.getByDataTest("answer-button").first().click() + cy.getByDataTest("submit").click() + + cy.getByDataTest("continue").should("be.visible") + cy.getByDataTest("continue").click() + cy.getByDataTest("submit").should("be.visible") + }) + + it.skip("game ends after wrong answer", () => { + cy.getByDataTest("answer-button").last().click() + cy.getByDataTest("submit").click() + cy.getByDataTest("end-game").should("be.visible") + cy.getByDataTest("end-game").click() + cy.contains("Game Over").should("be.visible") + }) + + it("game restarts after end game screen", () => { + cy.getByDataTest("answer-button").last().click() + cy.getByDataTest("submit").click() + cy.getByDataTest("end-game").click() + cy.contains("Try Again!").click() + cy.contains("GuildGesser").should("be.visible") + }) +}) diff --git a/cypress/e2e/3-guess-the-guild/1-assign-logos.spec.ts b/cypress/e2e/3-guess-the-guild/1-assign-logos.spec.ts new file mode 100644 index 000000000..a3ce2b5c0 --- /dev/null +++ b/cypress/e2e/3-guess-the-guild/1-assign-logos.spec.ts @@ -0,0 +1,31 @@ +const setGameModeToAssignLogos = () => { + Cypress.on("window:before:load", (win) => { + Object.defineProperty(win.Math, "random", { + value: function () { + return 0.9 + }, + writable: false, + }) + }) +} + +describe("assign logos game mode", () => { + beforeEach(() => { + cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, { + statusCode: 200, + fixture: "testGuilds.json", + }).as("guildsLoaded") + + cy.visit("/guess-the-guild") + setGameModeToAssignLogos() + cy.contains("Let's Go!").click() + }) + + it("not possible to press submit when not all logos are assigned", () => { + cy.getByDataTest("submit").should("be.disabled") + }) + + it("score indicator shows up", () => { + cy.get("#score-indicator").should("be.visible") + }) +}) diff --git a/cypress/fixtures/testGuilds.json b/cypress/fixtures/testGuilds.json new file mode 100644 index 000000000..00169c957 --- /dev/null +++ b/cypress/fixtures/testGuilds.json @@ -0,0 +1,46 @@ +[ + { + "id": 1, + "name": "Guild 1", + "urlName": "guild1", + "imageUrl": "", + "roles": ["Role1"], + "platforms": ["DISCORD"], + "memberCount": 1234, + "rolesCount": 5678, + "tags": ["VERIFIED"] + }, + { + "id": 2, + "name": "Guild 2", + "urlName": "guild2", + "imageUrl": "", + "roles": ["Role1"], + "platforms": ["DISCORD"], + "memberCount": 1234, + "rolesCount": 5678, + "tags": ["VERIFIED"] + }, + { + "id": 3, + "name": "Guild 3", + "urlName": "guild3", + "imageUrl": "", + "roles": ["Role1"], + "platforms": ["DISCORD"], + "memberCount": 1234, + "rolesCount": 5678, + "tags": ["VERIFIED"] + }, + { + "id": 4, + "name": "Guild 4", + "urlName": "guild4", + "imageUrl": "", + "roles": ["Role1"], + "platforms": ["DISCORD"], + "memberCount": 1234, + "rolesCount": 5678, + "tags": ["VERIFIED"] + } +] diff --git a/package-lock.json b/package-lock.json index d35742bbc..380412bc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@chakra-ui/anatomy": "^2.1.2", "@chakra-ui/react": "^2.6.1", "@chakra-ui/theme-tools": "^2.0.17", + "@dnd-kit/core": "^6.1.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@hcaptcha/react-hcaptcha": "^1.4.4", @@ -3235,6 +3236,42 @@ "ms": "^2.1.1" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", diff --git a/package.json b/package.json index 094b12834..df7a887cb 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@chakra-ui/anatomy": "^2.1.2", "@chakra-ui/react": "^2.6.1", "@chakra-ui/theme-tools": "^2.0.17", + "@dnd-kit/core": "^6.1.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@hcaptcha/react-hcaptcha": "^1.4.4", diff --git a/src/components/guess-the-guild/EndGame.tsx b/src/components/guess-the-guild/EndGame.tsx new file mode 100644 index 000000000..8b50f0c59 --- /dev/null +++ b/src/components/guess-the-guild/EndGame.tsx @@ -0,0 +1,81 @@ +import { Alert, Button, Divider, Heading, Text, VStack } from "@chakra-ui/react" +import pluralize from "utils/pluralize" + +const EndGame = ({ + score, + highscore, + onRestart, +}: { + score: number + highscore: number + onRestart: () => void +}) => { + const getMessage = () => { + if (score <= 10) return "Better luck next time!" + if (score <= 20) return "You're getting the hang of it!" + return "It looks like we're dealing with a professional 👀" + } + + return ( + <> + + + Game Over + + {getMessage()} + + + + Final Score + + + {score} points + + + {score > highscore && ( + <> + + 🎉 New Highscore! + + + )} + + {score <= highscore && ( + <> + + Only {pluralize(highscore - score + 1, "point")} to go + to beat your current highscore! + + + )} + + + + + + + ) +} + +export default EndGame diff --git a/src/components/guess-the-guild/StartGame.tsx b/src/components/guess-the-guild/StartGame.tsx new file mode 100644 index 000000000..5c03fe41a --- /dev/null +++ b/src/components/guess-the-guild/StartGame.tsx @@ -0,0 +1,111 @@ +import { Divider, Heading, Text, VStack } from "@chakra-ui/react" +import Button from "components/common/Button" +import { DIFFICULTY_GUILD_POOL_SIZE } from "pages/guess-the-guild" +import { Difficulty } from "./guess-the-guild-types" +import { DIFFICULTY_MULTIPLIERS } from "./hooks/useGameLogic" + +type Props = { + onStart: () => void + difficulty: Difficulty + onDifficultyChange: (d: Difficulty) => void + highscore: number +} + +const StartGame = ({ + onStart, + difficulty, + onDifficultyChange, + highscore, +}: Props) => ( + + + GuildGesser 1.0 + + + Are you an expert on Guilds?
Test your knowledge in our guild guesser + mini game! +
+ + + + + + {"Your highscore"} + + + {highscore} + + + + + + + {"Select Difficulty"} + + + + + + + + + Selection from the top {DIFFICULTY_GUILD_POOL_SIZE[difficulty]} guilds. +
+ Solving this difficulty awards {DIFFICULTY_MULTIPLIERS[difficulty]}x the + usual points. +
+
+ +
+) + +export default StartGame diff --git a/src/components/guess-the-guild/assign-logos/AssignLogos.tsx b/src/components/guess-the-guild/assign-logos/AssignLogos.tsx new file mode 100644 index 000000000..f2197a6af --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/AssignLogos.tsx @@ -0,0 +1,154 @@ +import { Divider, Heading, VStack } from "@chakra-ui/react" +import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core" +import Button from "components/common/Button" +import GuildLogo from "components/common/GuildLogo" +import React, { useMemo, useState } from "react" +import { GuildBase } from "types" +import ResultAlert from "../components/ResultAlert" +import { GameModeProps } from "../guess-the-guild-types" +import Draggable from "./components/Draggable" +import GuildCardWithDropzone from "./components/GuildCardWithDropzone" +import SourceDropzone from "./components/SourceDropzone" +import useDragAndDrop, { START_ZONE_ID } from "./hooks/useDragAndDrop" + +const AssignLogos = ({ guilds, onNext, onExit, onCorrect }: GameModeProps) => { + const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false) + const avatarSize = { base: "60px", sm: "70px", md: "80px" } + + const { startZone, dropzones, movingGuild, dragStart, dragEnd } = + useDragAndDrop(guilds) + + const handleDragStart = (event: DragStartEvent) => { + if (isAnswerSubmitted) return + dragStart(event) + } + + const handleDragEnd = (event: DragEndEvent) => { + if (isAnswerSubmitted) return + dragEnd(event) + } + + const renderDraggableAvatar = (guild: GuildBase) => { + if (!guild) return + return ( + + {guild.id != movingGuild?.id && ( + + + + )} + + ) + } + + const isAnswerCorrect = Object.entries(dropzones).every( + ([zoneId, guild]) => `${guild?.id}` === zoneId + ) + const isLogoCorrectForGuild = (guild: GuildBase) => + dropzones[guild.id]?.id === guild?.id + + const canSubmit = useMemo(() => { + for (const key of Object.keys(dropzones)) { + if (key !== "source" && dropzones[key] === null) { + return false + } + } + return true + }, [dropzones]) + + const handleSubmit = () => { + setIsAnswerSubmitted(true) + if (isAnswerCorrect) onCorrect() + } + + const liftUpAnimation = ` + @keyframes liftUp { + from { + transform: scale(1.0); + } + to { + transform: scale(1.1); + } + }` + + return ( + <> + + + + Guess the guild by the logo! + + + + {!isAnswerSubmitted && ( + <> + + {startZone.map((guild) => renderDraggableAvatar(guild))} + + + )} + + + {movingGuild && !isAnswerSubmitted ? ( + + ) : null} + + + {guilds.map((guild) => ( + + {renderDraggableAvatar(dropzones[`${guild.id}`])} + + ))} + + + + + {isAnswerSubmitted && } + + {!isAnswerSubmitted && ( + + )} + + {isAnswerSubmitted && isAnswerCorrect && ( + + )} + + {isAnswerSubmitted && !isAnswerCorrect && ( + + )} + + + ) +} + +export default AssignLogos diff --git a/src/components/guess-the-guild/assign-logos/components/Draggable.tsx b/src/components/guess-the-guild/assign-logos/components/Draggable.tsx new file mode 100644 index 000000000..8a79e4761 --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/components/Draggable.tsx @@ -0,0 +1,19 @@ +import { useDraggable } from "@dnd-kit/core" +import { ReactNode } from "react" + +const Draggable = ({ id, children }: { id: string; children?: ReactNode }) => { + const { attributes, listeners, setNodeRef } = useDraggable({ id }) + + return ( +
+ {children} +
+ ) +} + +export default Draggable diff --git a/src/components/guess-the-guild/assign-logos/components/GuildCardWithDropzone.tsx b/src/components/guess-the-guild/assign-logos/components/GuildCardWithDropzone.tsx new file mode 100644 index 000000000..576d92728 --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/components/GuildCardWithDropzone.tsx @@ -0,0 +1,98 @@ +import { + HStack, + Icon, + Tag, + TagLabel, + TagLeftIcon, + Text, + VStack, + Wrap, + useColorModeValue, +} from "@chakra-ui/react" +import Card from "components/common/Card" +import GuildLogo from "components/common/GuildLogo" +import { ArrowDown, Users } from "phosphor-react" +import { ReactNode } from "react" +import { GuildBase } from "types" +import pluralize from "utils/pluralize" +import TargetDropzone from "./TargetDropzone" + +const GuildCardWithDropzone = ({ + guild, + avatarSize, + children, + isAnswerSubmitted, + isLogoCorrect, +}: { + guild: GuildBase + avatarSize: any + children: ReactNode + isAnswerSubmitted: boolean + isLogoCorrect: boolean +}) => { + const bgColor = useColorModeValue("gray.100", "whiteAlpha.200") + + return ( + <> + + + + {children} + + + + {guild.name} + + + + {pluralize(guild.rolesCount, "role")} + + + + + {new Intl.NumberFormat("en", { notation: "compact" }).format( + guild.memberCount + )} + + + + + + + {isAnswerSubmitted && !isLogoCorrect && ( + <> + + + + + Correct Answer + + + + )} + + ) +} + +export default GuildCardWithDropzone diff --git a/src/components/guess-the-guild/assign-logos/components/SkeletonAssignLogos.tsx b/src/components/guess-the-guild/assign-logos/components/SkeletonAssignLogos.tsx new file mode 100644 index 000000000..5576ecae2 --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/components/SkeletonAssignLogos.tsx @@ -0,0 +1,50 @@ +import { + Card, + HStack, + SkeletonCircle, + SkeletonText, + VStack, + useColorModeValue, +} from "@chakra-ui/react" + +const SkeletonAssignLogos = () => { + const bgColor = useColorModeValue("gray.100", "whiteAlpha.200") + + return ( + + + + + + + + + + + + + + + + + + + + .{" "} + + + + + + + + + + + + + + ) +} + +export default SkeletonAssignLogos diff --git a/src/components/guess-the-guild/assign-logos/components/SourceDropzone.tsx b/src/components/guess-the-guild/assign-logos/components/SourceDropzone.tsx new file mode 100644 index 000000000..0b58106fe --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/components/SourceDropzone.tsx @@ -0,0 +1,55 @@ +import { Box, useColorModeValue, useTheme } from "@chakra-ui/react" +import { useDroppable } from "@dnd-kit/core" +import { ReactNode } from "react" + +const SourceDropzone = ({ + id, + children, + size, +}: { + id: string + children?: ReactNode + size: any +}) => { + const { isOver, setNodeRef } = useDroppable({ id }) + const borderColor = "transparent" + const borderColorHover = useColorModeValue("green.400", "green.600") + + const theme = useTheme() + const paddingValue = theme.space[2] + + const addPaddingToSize = (sizeValue) => `calc(${sizeValue} + 3*${paddingValue}) ` + + const minHeightWithPadding = () => { + const pSize = size + + return { + base: addPaddingToSize(pSize.base), + sm: addPaddingToSize(pSize.sm), + md: addPaddingToSize(pSize.md), + } + } + + return ( + + {children} + + ) +} + +export default SourceDropzone diff --git a/src/components/guess-the-guild/assign-logos/components/TargetDropzone.tsx b/src/components/guess-the-guild/assign-logos/components/TargetDropzone.tsx new file mode 100644 index 000000000..02f11f7aa --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/components/TargetDropzone.tsx @@ -0,0 +1,39 @@ +import { Box, useColorModeValue } from "@chakra-ui/react" +import { useDroppable } from "@dnd-kit/core" +import { ReactNode } from "react" + +const TargetDropzone = ({ + id, + children, + avatarSize, +}: { + id: string + children?: ReactNode + avatarSize: any +}) => { + const { isOver, setNodeRef } = useDroppable({ id }) + const borderColor = useColorModeValue("gray.300", "gray.500") + const borderColorHover = useColorModeValue("green.400", "green.600") + + return ( + + {children} + + ) +} + +export default TargetDropzone diff --git a/src/components/guess-the-guild/assign-logos/hooks/useDragAndDrop.tsx b/src/components/guess-the-guild/assign-logos/hooks/useDragAndDrop.tsx new file mode 100644 index 000000000..26f93b482 --- /dev/null +++ b/src/components/guess-the-guild/assign-logos/hooks/useDragAndDrop.tsx @@ -0,0 +1,105 @@ +import { DragEndEvent, DragStartEvent } from "@dnd-kit/core" +import { useState } from "react" +import { GuildBase } from "types" +import { shuffleArray } from "utils/shuffleArray" + +export const START_ZONE_ID = "source" +type DropzoneDict = Record + +const useDragAndDrop = (guilds: GuildBase[]) => { + const [startZone, setStartZone] = useState(shuffleArray(guilds)) + const [movingGuild, setMovingGuild] = useState(null) + + const initialDropzones = Object.fromEntries( + guilds.map((guild) => [guild.id, null]) + ) + + const [dropzones, setDropzones] = useState(initialDropzones) + + const addToStartZone = (guild: GuildBase) => { + setStartZone((prev) => [...prev, guild]) + } + + const removeFromStartZone = (guildId: number) => { + setStartZone((prev) => prev.filter((g) => g.id !== guildId)) + } + + const dragStart = (event: DragStartEvent) => { + setMovingGuild(guilds.find((g) => g.id.toString() === event.active.id)) + } + + const dragEnd = (event: DragEndEvent) => { + const { over } = event + + if (!over || !movingGuild) { + setMovingGuild(null) + return + } + + const sourceZoneId = findSourceZoneId(movingGuild) + const targetZoneId = over.id as string + + if (!isMoveAllowed(sourceZoneId, targetZoneId)) { + setMovingGuild(null) + return + } + + updateDropzones(sourceZoneId, targetZoneId) + updateStartZone(sourceZoneId, targetZoneId) + + setMovingGuild(null) + } + + const updateDropzones = (sourceZoneId: string, targetZoneId: string) => { + const updatedDropzones = { ...dropzones } + + if (targetZoneId !== START_ZONE_ID) { + updatedDropzones[targetZoneId] = guilds.find( + (guild) => guild.id === movingGuild.id + ) + } + + if (sourceZoneId !== START_ZONE_ID) { + updatedDropzones[sourceZoneId] = null + } + + setDropzones(updatedDropzones) + } + + const updateStartZone = (sourceZoneId: string, targetZoneId: string) => { + if (targetZoneId === START_ZONE_ID) { + addToStartZone(movingGuild) + } + if (sourceZoneId === START_ZONE_ID) { + removeFromStartZone(movingGuild.id) + } + } + + const isMoveAllowed = (sourceZoneId: string, targetZoneId: string): boolean => { + if (sourceZoneId === targetZoneId) return false + + if (targetZoneId !== START_ZONE_ID && dropzones[targetZoneId]) return false + + return true + } + + const findSourceZoneId = (guild: GuildBase): string | null => { + const isInStartZone = startZone.some((g) => g.id === guild.id) + if (isInStartZone) return START_ZONE_ID + + const sourceDropzone = Object.entries(dropzones).find( + ([_, dzGuild]) => dzGuild?.id === guild.id + ) + return sourceDropzone ? sourceDropzone[0] : null + } + + return { + startZone, + dropzones, + movingGuild, + dragStart, + dragEnd, + } +} + +export default useDragAndDrop diff --git a/src/components/guess-the-guild/components/ResultAlert.tsx b/src/components/guess-the-guild/components/ResultAlert.tsx new file mode 100644 index 000000000..aef5980b3 --- /dev/null +++ b/src/components/guess-the-guild/components/ResultAlert.tsx @@ -0,0 +1,15 @@ +import { Alert, AlertIcon } from "@chakra-ui/react" + +const ResultAlert = ({ isAnswerCorrect }: { isAnswerCorrect: boolean }) => ( + <> + + {isAnswerCorrect ? "Your answer is correct!" : "Wrong answer!"} + + +) + +export default ResultAlert diff --git a/src/components/guess-the-guild/components/ScoreIndicator.tsx b/src/components/guess-the-guild/components/ScoreIndicator.tsx new file mode 100644 index 000000000..9df5918f3 --- /dev/null +++ b/src/components/guess-the-guild/components/ScoreIndicator.tsx @@ -0,0 +1,56 @@ +import { Alert, HStack, Progress, Tag, chakra } from "@chakra-ui/react" +import Card from "components/common/Card" + +const ScoreIndicator = ({ + score, + highscore, +}: { + score: number + highscore: number +}) => { + const isHighscore = score > highscore + + return ( + + {!isHighscore && ( + <> + + + + Score:{" "} + {" "} + {score} + + + + Highscore:{" "} + {" "} + {highscore} + + + + + + )} + + {isHighscore && ( + <> + + 🎉 New Highscore!{" "} + + {score} points + + + + )} + + ) +} + +export default ScoreIndicator diff --git a/src/components/guess-the-guild/guess-name/GuessName.tsx b/src/components/guess-the-guild/guess-name/GuessName.tsx new file mode 100644 index 000000000..0fe5f537b --- /dev/null +++ b/src/components/guess-the-guild/guess-name/GuessName.tsx @@ -0,0 +1,85 @@ +import { Button, Divider, Heading, Text, VStack } from "@chakra-ui/react" +import GuildLogo from "components/common/GuildLogo" +import { useState } from "react" +import { GuildBase } from "types" +import ResultAlert from "../components/ResultAlert" +import { GameModeProps } from "../guess-the-guild-types" +import AnswerButton from "./components/AnswerButton" + +const getRandomGuild = (guilds: GuildBase[]) => + guilds[Math.floor(Math.random() * guilds.length)] + +const GuessName = ({ guilds, onNext, onExit, onCorrect }: GameModeProps) => { + const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false) + const [solutionGuild] = useState(getRandomGuild(guilds)) + const [selectedGuildId, setSelectedGuildId] = useState() + + const isAnswerCorrect = selectedGuildId === solutionGuild.id + + const handleSubmit = () => { + setIsAnswerSubmitted(true) + if (isAnswerCorrect) onCorrect() + } + + return ( + <> + + + Guess the guild by the logo! + + + + {isAnswerSubmitted ? solutionGuild.name : "???"} + + + + {guilds.map((guild) => ( + setSelectedGuildId(guild.id)} + /> + ))} + + + + + {isAnswerSubmitted && } + + {!isAnswerSubmitted && ( + + )} + + {isAnswerSubmitted && isAnswerCorrect && ( + + )} + + {isAnswerSubmitted && !isAnswerCorrect && ( + + )} + + + ) +} + +export default GuessName diff --git a/src/components/guess-the-guild/guess-name/components/AnswerButton.tsx b/src/components/guess-the-guild/guess-name/components/AnswerButton.tsx new file mode 100644 index 000000000..a4718b156 --- /dev/null +++ b/src/components/guess-the-guild/guess-name/components/AnswerButton.tsx @@ -0,0 +1,60 @@ +import Button from "components/common/Button" +import { GuildBase } from "types" + +type Props = { + guild: GuildBase + selectedGuildId?: number + solutionGuild: GuildBase + isAnswerSubmitted: boolean + onSelect: (guildId: number) => void +} + +const AnswerButton = ({ + guild, + selectedGuildId, + solutionGuild, + isAnswerSubmitted, + onSelect, +}: Props) => { + let colorScheme = "gray" + let isDisabled = false + let variant = "solid" + let isActive = false + + if (isAnswerSubmitted) { + if (guild.id === selectedGuildId) { + colorScheme = solutionGuild.id === selectedGuildId ? "green" : "red" + } else if (guild.id === solutionGuild.id) { + colorScheme = "green" + variant = "outline" + } else { + isDisabled = true + colorScheme = "gray" + variant = "solid" + } + } else { + isActive = selectedGuildId === guild.id + } + + const handleClick = (guildId: number) => { + if (isAnswerSubmitted) return + onSelect(guildId) + } + + return ( + + ) +} + +export default AnswerButton diff --git a/src/components/guess-the-guild/guess-name/components/SkeletonGuessName.tsx b/src/components/guess-the-guild/guess-name/components/SkeletonGuessName.tsx new file mode 100644 index 000000000..cdea0deb4 --- /dev/null +++ b/src/components/guess-the-guild/guess-name/components/SkeletonGuessName.tsx @@ -0,0 +1,13 @@ +import { Skeleton, SkeletonCircle, VStack } from "@chakra-ui/react" + +const SkeletonGuessName = () => ( + + + + + + + +) + +export default SkeletonGuessName diff --git a/src/components/guess-the-guild/guess-the-guild-types.tsx b/src/components/guess-the-guild/guess-the-guild-types.tsx new file mode 100644 index 000000000..eb210ff58 --- /dev/null +++ b/src/components/guess-the-guild/guess-the-guild-types.tsx @@ -0,0 +1,25 @@ +import { GuildBase } from "types" + +export enum GameMode { + GuessNameMode, + AssignLogosMode, +} + +export enum GameState { + Start, + Game, + End, +} + +export type GameModeProps = { + guilds: GuildBase[] + onNext: () => void + onExit: () => void + onCorrect: () => void +} + +export enum Difficulty { + Easy, + Medium, + Hard, +} diff --git a/src/components/guess-the-guild/hooks/useGameLogic.tsx b/src/components/guess-the-guild/hooks/useGameLogic.tsx new file mode 100644 index 000000000..c06c76934 --- /dev/null +++ b/src/components/guess-the-guild/hooks/useGameLogic.tsx @@ -0,0 +1,81 @@ +import useLocalStorage from "hooks/useLocalStorage" +import { useEffect, useState } from "react" +import { Difficulty, GameMode, GameState } from "../guess-the-guild-types" + +// The amount of points that a right answer in a game mode yields +export const GAME_MODE_POINTS: Record = { + [GameMode.GuessNameMode]: 1, + [GameMode.AssignLogosMode]: 2, +} + +// The earned points get multiplied by this factor depending upon the difficulty +export const DIFFICULTY_MULTIPLIERS: Record = { + [Difficulty.Easy]: 1, + [Difficulty.Medium]: 2, + [Difficulty.Hard]: 3, +} + +const useGameLogic = () => { + const [savedHighscore, setSavedHighscore] = useLocalStorage("highscore", 0) + const [score, setScore] = useState(0) + const [highscore, setHighscore] = useState(0) + const [round, setRound] = useState(0) + const [gameState, setGameState] = useState(GameState.Start) + const [gameMode, setGameMode] = useState(GameMode.GuessNameMode) + const [difficulty, setDifficulty] = useState(Difficulty.Easy) + + useEffect(() => { + setHighscore(savedHighscore) + }, []) + + const addPoints = () => { + const pointsToAdd = + GAME_MODE_POINTS[gameMode] * DIFFICULTY_MULTIPLIERS[difficulty] + const updatedPoints = score + pointsToAdd + setScore(updatedPoints) + updateHighscore(updatedPoints) + } + + const updateHighscore = (updatedPoints: number) => { + if (updatedPoints > highscore) { + setSavedHighscore(updatedPoints) + } + } + + const restartGame = () => { + setScore(0) + setRound(0) + setHighscore(savedHighscore) + setGameState(GameState.Start) + } + + const startGame = () => { + setGameState(GameState.Game) + selectGameMode() + } + + const selectGameMode = () => { + if (Math.random() >= 0.5) { + setGameMode(GameMode.AssignLogosMode) + } else { + setGameMode(GameMode.GuessNameMode) + } + } + + const endGame = () => { + setGameState(GameState.End) + } + + const nextGame = () => { + selectGameMode() + setRound(round + 1) + } + + return { + state: { score, highscore, round, difficulty, gameMode, gameState }, + transition: { startGame, endGame, restartGame, nextGame }, + action: { setDifficulty, addPoints }, + } +} + +export default useGameLogic diff --git a/src/pages/guess-the-guild.tsx b/src/pages/guess-the-guild.tsx new file mode 100644 index 000000000..a8f47c9d3 --- /dev/null +++ b/src/pages/guess-the-guild.tsx @@ -0,0 +1,181 @@ +import { Container, useBreakpointValue, useColorModeValue } from "@chakra-ui/react" +import Card from "components/common/Card" +import Layout from "components/common/Layout" +import EndGame from "components/guess-the-guild/EndGame" +import StartGame from "components/guess-the-guild/StartGame" +import AssignLogos from "components/guess-the-guild/assign-logos/AssignLogos" +import SkeletonAssignLogos from "components/guess-the-guild/assign-logos/components/SkeletonAssignLogos" +import ScoreIndicator from "components/guess-the-guild/components/ScoreIndicator" +import GuessName from "components/guess-the-guild/guess-name/GuessName" +import SkeletonGuessName from "components/guess-the-guild/guess-name/components/SkeletonGuessName" +import { + Difficulty, + GameMode, + GameState, +} from "components/guess-the-guild/guess-the-guild-types" +import useGameLogic from "components/guess-the-guild/hooks/useGameLogic" +import useShowErrorToast from "hooks/useShowErrorToast" +import React, { useEffect, useState } from "react" +import useSWR from "swr" +import { GuildBase } from "types" + +// The number of guilds to fetch from the API for each difficulty level +export const DIFFICULTY_GUILD_POOL_SIZE: Record = { + [Difficulty.Easy]: 100, + [Difficulty.Medium]: 500, + [Difficulty.Hard]: 1000, +} + +const GuessTheGuild = (): JSX.Element => { + const gameLogic = useGameLogic() + + const bgColor = useColorModeValue("var(--chakra-colors-gray-800)", "#37373a") + const bgOpacity = useColorModeValue(0.06, 0.1) + const bgLinearPercentage = useBreakpointValue({ base: "50%", sm: "55%" }) + + const apiUrl = `/v2/guilds?sort=members&limit=${ + DIFFICULTY_GUILD_POOL_SIZE[gameLogic.state.difficulty] + }` + + const [guilds, setGuilds] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const { data: allGuilds, error } = useSWR(apiUrl, { + revalidateOnFocus: false, + refreshInterval: 86400000, // 24 hours + }) + + const selectGuilds = () => { + if (allGuilds) setGuilds(allGuilds.sort(() => 0.5 - Math.random()).slice(0, 4)) + } + + useEffect(() => { + if (allGuilds) { + const guildsCopy = allGuilds.filter((guild) => guild.imageUrl) + setGuilds(guildsCopy.sort(() => 0.5 - Math.random()).slice(0, 4)) + setIsLoading(false) + } else { + setIsLoading(true) + } + }, [allGuilds]) + + const showErrorToast = useShowErrorToast() + + useEffect(() => { + if (error) showErrorToast("Failed to load guilds! Please try again later.") + }, [error, showErrorToast]) + + const handleStart = () => { + selectGuilds() + gameLogic.transition.startGame() + } + + const handleNext = () => { + selectGuilds() + gameLogic.transition.nextGame() + } + + const handleDifficultyChange = (diff: Difficulty) => { + gameLogic.action.setDifficulty(diff) + } + + return ( + <> + + + {/* Show points indicator when the game is on */} + {gameLogic.state.gameState === GameState.Game && ( + + )} + + + {/* Start Screen */} + {gameLogic.state.gameState === GameState.Start && ( + + )} + + {/* Game Screen */} + {gameLogic.state.gameState === GameState.Game && ( + + {/* Name Guessing Mode */} + {gameLogic.state.gameMode === GameMode.AssignLogosMode && ( + <> + {isLoading && } + {!isLoading && ( + <> + gameLogic.transition.endGame()} + onCorrect={() => gameLogic.action.addPoints()} + /> + + )} + + )} + + {/* Assign Logo Mode */} + {gameLogic.state.gameMode === GameMode.GuessNameMode && ( + <> + {isLoading && } + {!isLoading && ( + <> + gameLogic.transition.endGame()} + onCorrect={() => gameLogic.action.addPoints()} + /> + + )} + + )} + + )} + + {/* End Screen */} + {gameLogic.state.gameState === GameState.End && ( + + )} + + + + + ) +} + +export default GuessTheGuild diff --git a/src/utils/shuffleArray.ts b/src/utils/shuffleArray.ts new file mode 100644 index 000000000..2c3fd4db3 --- /dev/null +++ b/src/utils/shuffleArray.ts @@ -0,0 +1,2 @@ +export const shuffleArray = (array: any[]) => + [...array].sort(() => 0.5 - Math.random())