added basic quiz

This commit is contained in:
MasterGordon 2023-06-22 23:34:51 +02:00
parent 9086a0a87e
commit 1f03c34fb1
42 changed files with 1103 additions and 43 deletions

4
.env
View File

@ -1,2 +1,2 @@
APP_URL="http://localhost:3000"
WS_URL="ws://localhost:3001"
APP_URL="http://localhost:4000"
WS_URL="ws://localhost:4001"

7
.swcrc Normal file
View File

@ -0,0 +1,7 @@
{
"jsc": {
"experimental": {
"plugins": [["@swc-jotai/react-refresh", {}]]
}
}
}

View File

@ -1,6 +1,6 @@
# Gameshow 2023
## Getting Startet
## Getting Started
```bash
npm i -g pnpm@latest

View File

@ -9,8 +9,8 @@
"build:1-next": "cross-env NODE_ENV=production next build",
"build:2-server": "tsc --project tsconfig.server.json",
"build": "run-s build:*",
"dev:wss": "cross-env PORT=3001 tsx watch src/server/wssDevServer.ts --tsconfig tsconfig.server.json ",
"dev:next": "next dev",
"dev:wss": "cross-env PORT=4001 tsx watch src/server/wssDevServer.ts --tsconfig tsconfig.server.json ",
"dev:next": "next dev -p 4000",
"dev": "run-p dev:*",
"start": "cross-env NODE_ENV=production node dist/server/prodServer.js",
"lint": "eslint --cache --ext \".js,.ts,.tsx\" --report-unused-disable-directives --report-unused-disable-directives src",
@ -34,15 +34,18 @@
"clsx": "^1.1.1",
"framer-motion": "^10.12.16",
"fs-extra": "^11.1.1",
"jotai": "^2.2.1",
"next": "^13.4.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"superjson": "^1.7.4",
"ts-deepmerge": "^6.1.0",
"tsx": "^3.12.7",
"ws": "^8.0.0",
"zod": "^3.0.0"
},
"devDependencies": {
"@swc-jotai/react-refresh": "^0.0.8",
"@tanstack/react-query-devtools": "^4.18.0",
"@types/node": "^18.16.16",
"@types/react": "^18.2.8",

View File

@ -41,6 +41,9 @@ dependencies:
fs-extra:
specifier: ^11.1.1
version: 11.1.1
jotai:
specifier: ^2.2.1
version: 2.2.1(react@18.2.0)
next:
specifier: ^13.4.3
version: 13.4.6(react-dom@18.2.0)(react@18.2.0)
@ -53,6 +56,9 @@ dependencies:
superjson:
specifier: ^1.7.4
version: 1.12.3
ts-deepmerge:
specifier: ^6.1.0
version: 6.1.0
tsx:
specifier: ^3.12.7
version: 3.12.7
@ -64,6 +70,9 @@ dependencies:
version: 3.21.4
devDependencies:
'@swc-jotai/react-refresh':
specifier: ^0.0.8
version: 0.0.8
'@tanstack/react-query-devtools':
specifier: ^4.18.0
version: 4.29.15(@tanstack/react-query@4.29.15)(react-dom@18.2.0)(react@18.2.0)
@ -1814,6 +1823,10 @@ packages:
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
dev: true
/@swc-jotai/react-refresh@0.0.8:
resolution: {integrity: sha512-fHMenTg1jeETEShCh/wlDxRpR6DZAXIuDuFIB9fZ4yf+JfrWzQkPIvPN3E0efSpOham9ucRspS0uI82PxBbCjg==}
dev: true
/@swc/helpers@0.5.1:
resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
dependencies:
@ -3619,6 +3632,18 @@ packages:
'@sideway/pinpoint': 2.0.0
dev: true
/jotai@2.2.1(react@18.2.0):
resolution: {integrity: sha512-Gz4tpbRQy9OiFgBwF9F7TieDn0UTE3C0IFSDuxHjOIvgn2tACH30UKz6p/wIlfoZROXSTCIxEvYEa7Y25WM+8g==}
engines: {node: '>=12.20.0'}
peerDependencies:
react: '>=17.0.0'
peerDependenciesMeta:
react:
optional: true
dependencies:
react: 18.2.0
dev: false
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -4668,6 +4693,11 @@ packages:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
dev: false
/ts-deepmerge@6.1.0:
resolution: {integrity: sha512-YVJBhdIwYAZv6QoYz/mihpgbv+r0+QfQazTcSS6WXhQkbCxjTRoV+IOLtyArtz3au7xb+fPQVp1d7o5Qw1f1fg==}
engines: {node: '>=14.13.1'}
dev: false
/tsconfig-paths@3.14.2:
resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==}
dependencies:

BIN
public/flags/Antarktis.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/flags/Armenien.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

BIN
public/flags/Estland.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

BIN
public/flags/Georgien.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/flags/Ghana.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

BIN
public/flags/Grönland.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/flags/Kongo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

BIN
public/flags/Ägypten.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/thomas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -0,0 +1,68 @@
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
} from 'react';
import { GameState } from 'types/GameState';
import { trpc } from 'utils/trpc';
import merge from 'ts-deepmerge';
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
const useGameStateSync = () => {
const [gameState, setGameState] = useState<GameState | undefined>(undefined);
const gameStateQuery = trpc.game.get.useQuery();
const mutation = trpc.game.update.useMutation();
trpc.game.onUpdate.useSubscription(undefined, {
onData: (data) => {
setGameState(data);
},
});
useEffect(() => {
if (gameStateQuery.data) {
setGameState(gameStateQuery.data);
}
}, [gameStateQuery.data]);
const updateGameState = (newGameState: DeepPartial<GameState>) => {
if (gameState)
mutation.mutateAsync(
merge.withOptions(
{
mergeArrays: false,
},
gameState,
newGameState,
) as any,
);
};
return { gameState, updateGameState };
};
const gameStateContext = createContext<
ReturnType<typeof useGameStateSync> | undefined
>(undefined);
export const GameStateProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const value = useGameStateSync();
return (
<gameStateContext.Provider value={value}>
{children}
</gameStateContext.Provider>
);
};
export const useGameState = () => {
const context = useContext(gameStateContext);
if (context === undefined) {
throw new Error('useGameState must be used within a GameStateProvider');
}
return context;
};

View File

@ -1,3 +1,11 @@
import { extendTheme } from '@chakra-ui/react';
import { extendTheme, withDefaultColorScheme } from '@chakra-ui/react';
const theme = extendTheme({});
export const theme = extendTheme(
{
config: {
initialColorMode: 'light',
useSystemColorMode: false,
},
},
withDefaultColorScheme({ colorScheme: 'orange' }),
);

View File

@ -3,6 +3,7 @@ import { Button, HStack, Input, useNumberInput } from '@chakra-ui/react';
interface Props {
value: number;
onChange: (value: number) => void;
max?: number;
}
const CountInput: React.FC<Props> = (props) => {
@ -11,6 +12,7 @@ const CountInput: React.FC<Props> = (props) => {
step: 1,
value: props.value,
min: 0,
max: props.max,
onChange: (valueString) => {
const value = parseFloat(valueString);
if (isNaN(value)) {

View File

@ -1,34 +1,177 @@
import { HStack, Heading } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { GameState } from 'types/GameState';
import { trpc } from 'utils/trpc';
import {
Button,
Card,
CardBody,
CardHeader,
FormControl,
FormLabel,
HStack,
Heading,
Input,
Select,
Switch,
VStack,
chakra,
Text,
} from '@chakra-ui/react';
import CountInput from './components/CountInput';
import { useGameState } from 'client/hooks/useGameState';
import { questions } from 'questions';
import { ScreenType, screen } from 'types/GameState';
export const AdminPanel = () => {
const [gameState, setGameState] = useState<GameState | undefined>(undefined);
const gameStateQuery = trpc.game.get.useQuery();
const mutation = trpc.game.update.useMutation();
trpc.game.onUpdate.useSubscription(undefined, {
onData: (data) => {
setGameState(data);
},
});
useEffect(() => {
if (gameStateQuery.data) {
setGameState(gameStateQuery.data);
}
}, [gameStateQuery.data]);
const { gameState, updateGameState } = useGameState();
if (!gameState) return null;
const clearAll = () => {
const players = gameState.players.map((p) => {
return { ...p, answer: '' };
});
updateGameState({ players });
};
return (
<HStack alignItems="start">
<VStack alignItems="start" padding="2">
<Heading>Gameshow Admin Panel</Heading>
<CountInput
value={gameState.round}
onChange={(value) => {
mutation.mutate({ ...gameState, round: value });
}}
/>
</HStack>
<FormControl>
<FormLabel>Screen</FormLabel>
<Select
value={gameState.screen}
onChange={(e) =>
updateGameState({ screen: e.target.value as ScreenType })
}
>
{screen._def.options.map((o) => (
<chakra.option
key={o._def.value as string}
value={o._def.value as string}
>
{o._def.value as string}
</chakra.option>
))}
</Select>
</FormControl>
<FormControl>
<FormLabel>Catagory</FormLabel>
<CountInput
value={gameState.category}
onChange={(value) => {
const players = gameState.players.map((p) => {
return { ...p, answer: '' };
});
updateGameState({
players,
category: value,
round: 0,
showCorrectAnswer: false,
});
}}
max={questions.length - 1}
/>
</FormControl>
<FormControl>
<FormLabel>Round</FormLabel>
<CountInput
value={gameState.round}
onChange={(value) => {
const players = gameState.players.map((p) => {
return { ...p, answer: '' };
});
updateGameState({
players,
round: value,
showCorrectAnswer: false,
});
}}
max={questions[gameState.category].length - 1}
/>
</FormControl>
<FormControl>
<Text fontWeight="bold">Show Answer</Text>
<Switch
isChecked={gameState.showCorrectAnswer}
onChange={() => {
updateGameState({
showCorrectAnswer: !gameState.showCorrectAnswer,
});
}}
/>
</FormControl>
<FormControl>
<Text fontWeight="bold">Lock Answers</Text>
<Switch
isChecked={gameState.lockAnswers}
onChange={() => {
updateGameState({
lockAnswers: !gameState.lockAnswers,
});
}}
/>
</FormControl>
<Button onClick={clearAll}>Clear All Answers</Button>
<HStack>
{gameState.players.map((player) => (
<Card key={player.name}>
<CardHeader>{player.name}</CardHeader>
<CardBody gap="1em" display="flex" flexDirection="column">
<FormControl>
<FormLabel>Answer</FormLabel>
<Input value={player.answer} isDisabled />
</FormControl>
<FormControl>
<FormLabel>Score</FormLabel>
<CountInput
value={player.score}
onChange={(value) => {
const players = gameState.players.map((p) => {
if (p.name === player.name) {
return { ...p, score: value };
}
return p;
});
updateGameState({ players });
}}
/>
</FormControl>
<Button
onClick={() => {
const players = gameState.players.filter(
(p) => p.name !== player.name,
);
updateGameState({ players });
}}
>
Delete
</Button>
<FormControl>
<Text fontWeight="bold">Show Answer</Text>
<Switch
isChecked={player.showAnswer}
onChange={() => {
const players = gameState.players.map((p) => {
if (p.name === player.name) {
return { ...p, showAnswer: !p.showAnswer };
}
return p;
});
updateGameState({
players,
});
}}
/>
</FormControl>
</CardBody>
</Card>
))}
</HStack>
<pre>
{JSON.stringify(
questions[gameState.category][gameState.round],
undefined,
2,
)}
</pre>
</VStack>
);
};

View File

@ -0,0 +1,67 @@
import { useGameState } from 'client/hooks/useGameState';
import Welcome from './screens/Welcome';
import { CanvasProvider } from '../canvasContext';
import { questions } from 'questions';
import Question from '../Player/scenes/Question';
import { Flex, Grid, Heading } from '@chakra-ui/react';
const Canvas: React.FC = () => {
const { gameState } = useGameState();
if (!gameState) return null;
const players = [
{ score: 0, name: 'Der große Saurier' },
{ score: 0, name: 'Zwei Hühner' },
{ score: 0, name: 'Bongo der Herrscher' },
{ score: 0, name: 'Bongo der D' },
{ score: 0, name: 'Zwei Keks' },
{ score: 0, name: 'Drei Keks' },
{ score: 0, name: 'Quadro keks' },
];
const currentQuestion = questions[gameState.category][gameState.round];
return (
<>
<CanvasProvider>
{gameState.screen === 'welcome' ? (
<Welcome />
) : (
<Flex
flexDirection="column"
minHeight="100vh"
justifyContent="space-between"
>
<Question question={currentQuestion} />
<Grid
justifyContent="space-between"
padding="64px"
flexWrap="wrap"
templateColumns="repeat(auto-fit,minmax(300px,1fr));"
gap="32px"
>
{players.map((player) => (
<Flex
justifySelf="center"
width="300px"
key={player.name}
flexDirection="column"
alignItems="center"
paddingX="8px"
>
<Heading size="sm" whiteSpace="normal" textAlign="center">
{player.name}
</Heading>
<Heading color="orange.600" size="sm">
{player.score}
</Heading>
</Flex>
))}
</Grid>
</Flex>
)}
</CanvasProvider>
<style>{`html {font-size: 32px; overflow: hidden}`}</style>
</>
);
};
export default Canvas;

View File

@ -0,0 +1,57 @@
import { Center, Heading, Img, keyframes } from '@chakra-ui/react';
const animation = keyframes`
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(-10deg);
}
75% {
transform: rotate(10deg);
}
0% {
transform: rotate(0deg);
}
`;
const animation2 = keyframes`
0% {
transform: rotate(0deg);
}
30% {
transform: rotate(-10deg);
}
60% {
transform: rotate(0deg) scaleX(-1);
}
80% {
transform: rotate(10deg) scaleX(-1);
}
100% {
transform: rotate(0deg) scaleX(1);
}
`;
const Welcome: React.FC = () => {
return (
<Center h="100vh" w="100vw">
<Heading>Willkommen zur Gameshow!</Heading>
<Img
position="absolute"
src="/thomas.png"
animation={`${animation} 2s infinite`}
left="0"
/>
<Img
position="absolute"
src="/thomas.png"
animation={`${animation2} 2s infinite`}
right="0"
transform="scaleX(-1)"
/>
</Center>
);
};
export default Welcome;

View File

@ -0,0 +1,16 @@
import { useGameState } from 'client/hooks/useGameState';
import { usePlayerName } from './playerAtom';
import { Join } from './scenes/Join';
import { questions } from 'questions';
import Question from './scenes/Question';
export const Player: React.FC = () => {
const [playerName] = usePlayerName();
const { gameState } = useGameState();
if (!gameState) return null;
const currentQuestion = questions[gameState.category][gameState.round];
return playerName ? <Question question={currentQuestion} /> : <Join />;
};

View File

@ -0,0 +1,25 @@
import { useGameState } from 'client/hooks/useGameState';
import { atom, useAtom } from 'jotai';
export const playerNameAtom = atom('');
export const usePlayerName = () => {
return useAtom(playerNameAtom);
};
export const usePlayer = () => {
const { gameState, updateGameState } = useGameState();
const [playerName] = usePlayerName();
const player = gameState?.players.find((p) => p.name === playerName);
const sendAnswer = (answer: string) => {
if (!player) return;
player.answer = answer;
const players = gameState?.players.map((p) => {
if (p.name === playerName) {
return player;
}
return p;
});
updateGameState({ players });
};
return { player, sendAnswer };
};

View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { usePlayerName } from '../playerAtom';
import { useGameState } from 'client/hooks/useGameState';
import {
Button,
FormControl,
FormLabel,
Heading,
Input,
VStack,
} from '@chakra-ui/react';
export const Join: React.FC = () => {
const [playerName, setPlayerName] = useState('');
const { gameState, updateGameState } = useGameState();
const [, setPlayerNameAtom] = usePlayerName();
const onSubmit = () => {
setPlayerNameAtom(playerName);
if (!gameState) return;
if (gameState?.players.some((player) => player.name === playerName)) return;
updateGameState({
players: [
...gameState.players,
{ name: playerName, answer: '', score: 0, showAnswer: false },
],
});
};
return (
<VStack padding="2">
<Heading>Beitreten</Heading>
<FormControl>
<FormLabel>Team Name</FormLabel>
<Input
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
/>
</FormControl>
<Button onClick={onSubmit}>Beitreten</Button>
</VStack>
);
};

View File

@ -0,0 +1,37 @@
import { Input, Heading, VStack } from '@chakra-ui/react';
import { EstimationQuestion } from 'types/Question';
import { usePlayer } from '../../playerAtom';
import { useGameState } from 'client/hooks/useGameState';
import { useIsCanvas } from 'client/view/canvasContext';
const EstimationQuestionComponent: React.FC<{
question: EstimationQuestion;
}> = ({ question }) => {
const { sendAnswer } = usePlayer();
const { gameState } = useGameState();
const isCanvas = useIsCanvas();
return (
<VStack padding="32px" flexDir="column" gap="32px">
<Heading size="md">{question.question}</Heading>
{isCanvas && gameState?.showCorrectAnswer ? (
<Heading size="md" color="orange.600">
{question.rightAnswer}
</Heading>
) : (
<>
{!isCanvas && (
<Input
maxWidth="600px"
placeholder="Antwort"
onChange={(event) => sendAnswer(event.target.value)}
isDisabled={gameState?.lockAnswers}
/>
)}
</>
)}
</VStack>
);
};
export default EstimationQuestionComponent;

View File

@ -0,0 +1,41 @@
import { Input, Image, VStack, Heading } from '@chakra-ui/react';
import { FlagQuestion } from 'types/Question';
import { usePlayer } from '../../playerAtom';
import { useGameState } from 'client/hooks/useGameState';
import { useIsCanvas } from 'client/view/canvasContext';
const FlagQuestionComponent: React.FC<{
question: FlagQuestion;
}> = ({ question }) => {
const { sendAnswer } = usePlayer();
const { gameState } = useGameState();
const isCanvas = useIsCanvas();
return (
<VStack padding="32px" flexDir="column" gap="32px">
<Image
src={question.image}
height="auto"
width="600px"
border="1px solid grey"
/>
{isCanvas && gameState?.showCorrectAnswer ? (
<Heading size="md" color="orange.600">
{question.rightAnswer}
</Heading>
) : (
<>
{!isCanvas && (
<Input
placeholder="Antwort"
onChange={(event) => sendAnswer(event.target.value)}
disabled={gameState?.lockAnswers}
/>
)}
</>
)}
</VStack>
);
};
export default FlagQuestionComponent;

View File

@ -0,0 +1,44 @@
import { Button, VStack } from '@chakra-ui/react';
import { LawQuestion } from 'types/Question';
import { usePlayer } from '../../playerAtom';
import { useGameState } from 'client/hooks/useGameState';
import { useIsCanvas } from 'client/view/canvasContext';
const letters = ['A', 'B'];
const LawQuestionComponent: React.FC<{
question: LawQuestion;
}> = ({ question }) => {
const { sendAnswer, player } = usePlayer();
const { gameState } = useGameState();
const isCanvas = useIsCanvas();
return (
<VStack padding="32px" flexDir="column" gap="32px">
{question.laws.map((law, index) => (
<Button
key={law}
onClick={() => sendAnswer(letters[index])}
width="100%"
padding="8px"
whiteSpace="normal"
isDisabled={!isCanvas && gameState?.lockAnswers}
height="fit-content"
backgroundColor={
(isCanvas &&
gameState?.showCorrectAnswer &&
question.rightAnswer === letters[index]) ||
player?.answer === letters[index]
? 'blue.600'
: undefined
}
_hover={{ backgroundColor: 'blue.600' }}
>
{`${letters[index]}: ${law}`}
</Button>
))}
</VStack>
);
};
export default LawQuestionComponent;

View File

@ -0,0 +1,38 @@
import { Input, Heading, VStack } from '@chakra-ui/react';
import { MovieQuestion } from 'types/Question';
import { usePlayer } from '../../playerAtom';
import { useGameState } from 'client/hooks/useGameState';
import { useIsCanvas } from 'client/view/canvasContext';
const MovieQuestionComponent: React.FC<{
question: MovieQuestion;
}> = ({ question }) => {
const { sendAnswer } = usePlayer();
const { gameState } = useGameState();
const isCanvas = useIsCanvas();
return (
<VStack padding="32px" flexDir="column" gap="32px">
<Heading size="md" textAlign="center">
{question.description}
</Heading>
{isCanvas && gameState?.showCorrectAnswer ? (
<Heading size="md" color="orange.600">
{question.movie}
</Heading>
) : (
<>
{!isCanvas && (
<Input
placeholder="Antwort"
onChange={(event) => sendAnswer(event.target.value)}
disabled={gameState?.lockAnswers}
/>
)}
</>
)}
</VStack>
);
};
export default MovieQuestionComponent;

View File

@ -0,0 +1,47 @@
import { Button, Flex, Heading, VStack } from '@chakra-ui/react';
import { MultipleChoiceQuestion } from 'types/Question';
import { usePlayer } from '../../playerAtom';
import { useGameState } from 'client/hooks/useGameState';
import { useIsCanvas } from 'client/view/canvasContext';
const letters = ['A', 'B', 'C', 'D'];
const MultipleChoiceQuestionComponent: React.FC<{
question: MultipleChoiceQuestion;
}> = ({ question }) => {
const { sendAnswer, player } = usePlayer();
const { gameState } = useGameState();
const isCanvas = useIsCanvas();
return (
<VStack padding="32px" flexDir="column" gap="32px">
<Heading size="md">{question.question}</Heading>
<Flex flexWrap="wrap" gap="16px" justifyContent="center">
{question.choices.map((choice, index) => (
<Button
key={choice}
onClick={() => sendAnswer(letters[index])}
width="100%"
padding="8px"
whiteSpace="normal"
isDisabled={!isCanvas && gameState?.lockAnswers}
height="fit-content"
backgroundColor={
(isCanvas &&
gameState?.showCorrectAnswer &&
question.rightAnswer === letters[index]) ||
player?.answer === letters[index]
? 'blue.600'
: undefined
}
_hover={{ backgroundColor: 'blue.600' }}
>
{`${letters[index]}: ${choice}`}
</Button>
))}
</Flex>
</VStack>
);
};
export default MultipleChoiceQuestionComponent;

View File

@ -0,0 +1,22 @@
import { Question } from 'types/Question';
import MultipleChoiceQuestionComponent from './MultipleChoiceQuestion';
import EstimationQuestionComponent from './EstimationQuestion';
import FlagQuestionComponent from './FlagQuestion';
import MovieQuestionComponent from './MovieQuestion';
import LawQuestionComponent from './LawQuestion';
const components: Record<Question['type'], React.FC<{ question: any }>> = {
'multiple-choice': MultipleChoiceQuestionComponent,
estimation: EstimationQuestionComponent,
flag: FlagQuestionComponent,
law: LawQuestionComponent,
movie: MovieQuestionComponent,
};
const Question: React.FC<{ question: Question }> = ({ question }) => {
const QuestionComponent = components[question.type];
return <QuestionComponent question={question} />;
};
export default Question;

View File

@ -0,0 +1,13 @@
import { PropsWithChildren, createContext, useContext } from 'react';
const canvasContext = createContext(false);
export const CanvasProvider: React.FC<PropsWithChildren> = ({ children }) => {
return (
<canvasContext.Provider value={true}>{children}</canvasContext.Provider>
);
};
export const useIsCanvas = () => {
return useContext(canvasContext);
};

View File

@ -1,11 +1,15 @@
import { ChakraProvider } from '@chakra-ui/react';
import { GameStateProvider } from 'client/hooks/useGameState';
import { theme } from 'client/theme';
import type { AppType } from 'next/app';
import { trpc } from 'utils/trpc';
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<ChakraProvider>
<Component {...pageProps} />
<ChakraProvider theme={theme}>
<GameStateProvider>
<Component {...pageProps} />
</GameStateProvider>
</ChakraProvider>
);
};

7
src/pages/canvas.tsx Normal file
View File

@ -0,0 +1,7 @@
import Canvas from 'client/view/Canvas';
const CanvasPage = () => {
return <Canvas />;
};
export default CanvasPage;

View File

@ -1,12 +1,8 @@
import { Button } from '@chakra-ui/react';
import { Player } from 'client/view/Player';
import { NextPage } from 'next';
const IndexPage: NextPage = () => {
return (
<>
<Button>Test</Button>
</>
);
return <Player />;
};
export default IndexPage;

293
src/questions.ts Normal file
View File

@ -0,0 +1,293 @@
import {
EstimationQuestion,
FlagQuestion,
MovieQuestion,
MultipleChoiceQuestion,
Question,
LawQuestion,
} from 'types/Question';
export const multipleChoiceQuestion: MultipleChoiceQuestion[] = [
{
type: 'multiple-choice',
question: 'Ist eine Frage mit ganz viel Test gut?',
choices: ['Test1', 'Test2', 'Test3', 'Test4'],
rightAnswer: 'A',
},
{
type: 'multiple-choice',
question: 'Woraus wurde früher violetter Farbstoff gewonnen?',
choices: ['Safran', 'Schnecken', 'Lapislazuli', 'Rittersporn'],
rightAnswer: 'C',
},
{
type: 'multiple-choice',
question: 'Was ist die größte Hühnerrasse der Welt?',
choices: [
'Jersey Huhn',
'Plymouth Rock Huhn',
'Cochin Huhn',
'Orpington Huhn',
],
rightAnswer: 'A',
},
{
type: 'multiple-choice',
question: 'Was bedeutet Photochemie?',
choices: [
'Die Photochemie befasst sich mit den chemischen Reaktionen, die durch die Absorption von Lichtenergie in einer Substanz ausgelöst werden',
'Photochemie befasst sich mit den chemischen Reaktionen in Fotosynthese',
'Photochemie befasst sich mit chemischen Reaktionen, die bei der Entwicklung von Fotografien auftreten',
'Photochemie beschäftigt sich mit der chemischen Veränderung von Farben in Fotos',
],
rightAnswer: 'A',
},
{
type: 'multiple-choice',
question: 'Wer hat die Sixtinische Kapelle bemalt?',
choices: ['Michelangelo', 'Da Vinci', 'Raphaël', 'Caravaggio'],
rightAnswer: 'D',
},
{
type: 'multiple-choice',
question:
'Welche Dynastie herrschte im alten China von 221 v.Chr. bis 206 v.Chr.? ',
choices: [
'Han-Dynastie',
'Qin-Dynastie',
'Zhou-Dynastie',
' Ming-Dynastie',
],
rightAnswer: 'B',
},
{
type: 'multiple-choice',
question: 'Was war die erfolgreichste Serie 2022?',
choices: [
'Wednesday',
'Stranger Things',
'Euphoria',
'House of the Dragon',
],
rightAnswer: 'B',
},
{
type: 'multiple-choice',
question: 'Welche Nudelsorte existiert nicht?',
choices: ['Trofie', 'Orecchiette', 'Circoliniello', 'Pappardelle'],
rightAnswer: 'C',
},
{
type: 'multiple-choice',
question: 'Wo wurde das erste mal über das Einhorn berichtet?',
choices: [
'Griechische Mythologie',
'Römische Mythologie',
'Indischen Volkserzählungen',
'Altes Testament',
],
rightAnswer: 'D',
},
];
export const estimationQuestion: EstimationQuestion[] = [
{
type: 'estimation',
question: 'Wie viele Tierarten der Erde sind noch unentdeckt?',
rightAnswer: 'ca. 89%',
},
{
type: 'estimation',
question: 'Wie viele Monde gibt es in unserem Sonnensystem?',
rightAnswer: '170',
},
{
type: 'estimation',
question: 'Wie viel Gramm ist eine Unze? ',
rightAnswer: '27g',
},
{
type: 'estimation',
question: 'Wie viele Bücher werden im Jahr in Deutschland verkauft?',
rightAnswer: '273 Millionen',
},
{
type: 'estimation',
question: 'Wann wurde das erste Handy erfunden?',
rightAnswer: '1973',
},
{
type: 'estimation',
question: 'Wann wurde der Blobfisch entdeckt?',
rightAnswer: '1926 ',
},
{
type: 'estimation',
question: 'Was ist der größte Abstand zwischen Sonne und Pluto?',
rightAnswer: '7.381 Millionen Kilometer',
},
{
type: 'estimation',
question: 'Wie lange ist eine ausgerollte Lakritzschnecke?',
rightAnswer: '50cm',
},
];
export const flagQuestion: FlagQuestion[] = [
{
type: 'flag',
image: '/flags/Estland.webp',
rightAnswer: 'Estland',
},
{
type: 'flag',
image: '/flags/Palästina.webp',
rightAnswer: 'Palästina',
},
{
type: 'flag',
image: '/flags/Kongo.webp',
rightAnswer: 'Dr Kongo',
},
{
type: 'flag',
image: '/flags/Armenien.webp',
rightAnswer: 'Armenien',
},
{
type: 'flag',
image: '/flags/Ghana.webp',
rightAnswer: 'Ghana',
},
{
type: 'flag',
image: '/flags/Ägypten.webp',
rightAnswer: 'Ägypten',
},
{
type: 'flag',
image: '/flags/Antarktis.webp',
rightAnswer: '',
},
{
type: 'flag',
image: '/flags/Georgien.webp',
rightAnswer: 'Georgien',
},
{
type: 'flag',
image: '/flags/Grönland.webp',
rightAnswer: 'Grönland',
},
{
type: 'flag',
image: '/flags/Neuseeland.webp',
rightAnswer: 'Neuseeland',
},
];
export const stupidLawQuestion: LawQuestion[] = [
{
type: 'law',
laws: [
'Die Stadt Leadwood, Missouri, hat es Piloten verboten, während des Fluges Wassermelonen zu essen',
'In der Stadt Keelung, Taiwan, ist illegal mit einer Wassermelone im Bus zu fahren',
],
rightAnswer: 'A',
},
{
type: 'law',
laws: [
'In der italienischen Stadt Eraclea ist es verboten, Sandburgen zu bauen',
'In Neapel, Italien, ist es gesetzlich verboten am Strand einen Sonnenbrand zu bekommen',
],
rightAnswer: 'A',
},
{
type: 'law',
laws: [
'In Kalifornien ist es gesetzlich verboten, Schere, Stein, Papier um Geld zu spielen, es sei denn, es handelt sich um eine offiziell genehmigte Wettveranstaltung',
'In Alabama ist Dominospielen am Sonntag streng verboten',
],
rightAnswer: 'B',
},
{
type: 'law',
laws: [
'In Tennessee ist es gesetzlich verboten, einen Elch aus einem fahrenden Fahrzeug heraus zu erschießen',
'In Florida sind sexuelle Beziehungen mit Stachelschweinen verboten',
],
rightAnswer: 'B',
},
{
type: 'law',
laws: [
'Jedes Londoner Taxi muss laut Gesetz einen Heuballen im Kofferraum mit sich führen',
'Gemäß Vorschriften in Amsterdam muss an Feiertagen jeder Fahrradfahrer eine frische Tulpe am Lenker seines Fahrrads befestigen',
],
rightAnswer: 'A',
},
];
export const movieQuestion: MovieQuestion[] = [
{
type: 'movie',
description:
'Ein Typ allein im Wald küsst eine Leiche während sieben andere Typen zusehen',
genre: 'Disney',
movie: 'Schneewittchen',
},
{
type: 'movie',
description: 'Eineinhalb Stunden lang Leuten beim Schlafen zusehen',
genre: 'Science Fiction',
movie: 'Inception',
},
{
type: 'movie',
description: 'Sprechender Frosch überredet Sohn, seinen Vater zu töten',
genre: 'Science Fiction / Fantasy',
movie: 'Star Wars',
},
{
type: 'movie',
description:
'Unbeliebte Kinder machen einen hungernden, obdachlosen Clown fertig',
genre: 'Horror',
movie: 'Es',
},
{
type: 'movie',
description:
'Ältere Schwester verdirbt der jüngeren die Chance auf einen TV-Auftritt',
genre: 'Dystopie',
movie: 'Die Tribute von Panem',
},
{
type: 'movie',
description:
'Mädchen muss sich als Junge ausgeben, um ernst genommen zu werden',
genre: 'Disney',
movie: 'Mulan',
},
{
type: 'movie',
description: 'Typ lernt, ein Mädchen ohne ihre Instagram-Filter zu lieben',
genre: 'Animation',
movie: 'Shrek',
},
{
type: 'movie',
description: 'Eine Gruppe verbringt 9 Stunden damit, Schmuck zurückzugeben',
genre: 'Fantasy',
movie: 'Herr der Ringe',
},
];
export const questions: Question[][] = [
multipleChoiceQuestion,
estimationQuestion,
flagQuestion,
stupidLawQuestion,
movieQuestion,
];

View File

@ -4,6 +4,10 @@ import { GameState } from 'types/GameState';
const initialState: GameState = {
round: 0,
players: [],
category: 0,
lockAnswers: false,
showCorrectAnswer: false,
screen: 'question',
};
export const getGameState = (): GameState => {

View File

@ -6,7 +6,7 @@ import next from 'next';
import { parse } from 'url';
import ws from 'ws';
const port = parseInt(process.env.PORT || '3000', 10);
const port = parseInt(process.env.PORT || '4000', 10);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

View File

@ -4,7 +4,7 @@ import { applyWSSHandler } from '@trpc/server/adapters/ws';
import ws from 'ws';
const wss = new ws.Server({
port: 3001,
port: 4001,
});
const handler = applyWSSHandler({ wss, router: appRouter, createContext });
@ -14,7 +14,7 @@ wss.on('connection', (ws) => {
console.log(` Connection (${wss.clients.size})`);
});
});
console.log('✅ WebSocket Server listening on ws://localhost:3001');
console.log('✅ WebSocket Server listening on ws://localhost:4001');
process.on('SIGTERM', () => {
console.log('SIGTERM');

View File

@ -1,14 +1,23 @@
import { z } from 'zod';
export const screen = z.union([z.literal('welcome'), z.literal('question')]);
export type ScreenType = z.infer<typeof screen>;
export const playerSchema = z.object({
name: z.string(),
score: z.number(),
answer: z.string(),
showAnswer: z.boolean(),
});
export const gameStateSchema = z.object({
round: z.number(),
category: z.number(),
players: z.array(playerSchema),
showCorrectAnswer: z.boolean(),
lockAnswers: z.boolean(),
screen,
});
export type GameState = z.infer<typeof gameStateSchema>;

38
src/types/Question.ts Normal file
View File

@ -0,0 +1,38 @@
export interface MultipleChoiceQuestion {
type: 'multiple-choice';
question: string;
choices: string[];
rightAnswer: string;
}
export interface EstimationQuestion {
type: 'estimation';
question: string;
rightAnswer: string;
}
export interface FlagQuestion {
type: 'flag';
image: string;
rightAnswer: string;
}
export interface MovieQuestion {
type: 'movie';
description: string;
genre: string;
movie: string;
}
export interface LawQuestion {
type: 'law';
laws: string[];
rightAnswer: string;
}
export type Question =
| MultipleChoiceQuestion
| EstimationQuestion
| FlagQuestion
| MovieQuestion
| LawQuestion;