Compare commits

..

8 Commits

15 changed files with 188 additions and 41 deletions

View File

@@ -1,19 +1,33 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { debounce } from "./utils"; import { debounce } from "./utils";
import { socket } from "./socket"; import { socket } from "./socket";
import { useAppSelector } from "./hooks"; import { useAppSelector } from "./hooks";
import { selectCanBuzz, selectSignature } from "./store/contestantSlice"; import { selectSignature } from "./store/contestantSlice";
const Contestant = () => { const Contestant = () => {
const { room } = useParams(); const { room } = useParams();
const signature = useAppSelector(selectSignature); const signature = useAppSelector(selectSignature);
const canBuzz = useAppSelector(selectCanBuzz); const [canBuzz, setCanBuzz] = useState<boolean>(false);
const [active, setActive] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
socket.emit("contestant-join", { room, signature }); socket.emit("contestant-join", { room, signature });
socket.on("clue-clock-on", () => setCanBuzz(true));
socket.on("clue-clock-off", () => setCanBuzz(false));
socket.on("contestant-buzzed", ({ sid }) => {
setCanBuzz(false);
if (sid === socket.id) {
setActive(true);
}
});
socket.on("contestant-scores", () => setActive(false));
socket.on("contestant-penalized", () => {
setActive(false);
setCanBuzz(true);
});
}, []); }, []);
const handleBuzz = debounce(() => { const handleBuzz = debounce(() => {
@@ -29,7 +43,7 @@ const Contestant = () => {
cx={50} cx={50}
cy={50} cy={50}
r={30} r={30}
fill={canBuzz ? "red" : "grey"} fill={active ? "green" : canBuzz ? "red" : "grey"}
stroke="black" stroke="black"
onClick={handleBuzz} onClick={handleBuzz}
/> />

View File

@@ -3,10 +3,14 @@ import { Stack, Container } from "react-bootstrap";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { socket } from "./socket"; import { socket } from "./socket";
import { useAppSelector } from "./hooks"; import { useAppSelector, useAppDispatch } from "./hooks";
import { selectRoomCode } from "./store/socketSlice"; import { selectRoomCode } from "./store/commonSlice";
import { selectContestants } from "./store/displaySlice"; import { selectContestants } from "./store/displaySlice";
import { selectCategories, selectActiveClue } from "./store/cluesSlice"; import {
selectCategories,
selectActiveClue,
clearActiveClue,
} from "./store/cluesSlice";
import ContestantWidget from "./ContestantWidget"; import ContestantWidget from "./ContestantWidget";
import Gameboard from "./display/Gameboard"; import Gameboard from "./display/Gameboard";
import ClueDisplay from "./display/ClueDisplay"; import ClueDisplay from "./display/ClueDisplay";
@@ -16,9 +20,13 @@ const Display = () => {
const contestants = useAppSelector(selectContestants); const contestants = useAppSelector(selectContestants);
const categories = useAppSelector(selectCategories); const categories = useAppSelector(selectCategories);
const activeClue = useAppSelector(selectActiveClue); const activeClue = useAppSelector(selectActiveClue);
const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
console.log(socket);
socket.emit("new-game"); socket.emit("new-game");
socket.on("clue-clock-off", () => dispatch(clearActiveClue()));
socket.on("contestant-scores", () => dispatch(clearActiveClue()));
}, []); }, []);
return ( return (

View File

@@ -2,8 +2,12 @@ import { useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { socket } from "./socket"; import { socket } from "./socket";
import { useAppSelector } from "./hooks"; import { useAppSelector, useAppDispatch } from "./hooks";
import { selectCategories, selectActiveClue } from "./store/cluesSlice"; import {
selectCategories,
selectActiveClue,
clearActiveClue,
} from "./store/cluesSlice";
import Pregame from "./host/Pregame"; import Pregame from "./host/Pregame";
import ActiveClue from "./host/ActiveClue"; import ActiveClue from "./host/ActiveClue";
import CluesDisplay from "./host/CluesDisplay"; import CluesDisplay from "./host/CluesDisplay";
@@ -11,11 +15,15 @@ import CluesDisplay from "./host/CluesDisplay";
const Host = () => { const Host = () => {
const { room } = useParams(); const { room } = useParams();
const dispatch = useAppDispatch();
const activeClue = useAppSelector(selectActiveClue); const activeClue = useAppSelector(selectActiveClue);
const categories = useAppSelector(selectCategories); const categories = useAppSelector(selectCategories);
useEffect(() => { useEffect(() => {
socket.emit("host-join", { room }); socket.emit("host-join", { room });
socket.on("contestant-scores", () => dispatch(clearActiveClue()));
socket.on("clue-clock-off", () => dispatch(clearActiveClue()));
}, []); }, []);
if (categories.length < 6) { if (categories.length < 6) {

21
src/TimerWidget.tsx Normal file
View File

@@ -0,0 +1,21 @@
interface Props {
segs: number;
}
const TimerWidget = ({ segs }: Props) => (
<svg viewBox="0 0 90 50" style={{ width: "100%", height: "100%" }}>
{[5, 4, 3, 2, 1, 2, 3, 4, 5].map((seg, i) => (
<rect
x={i * 10}
y="0"
width="10"
height="5"
key={i}
fill={seg <= segs ? "yellow" : "black"}
stroke="blue"
/>
))}
</svg>
);
export default TimerWidget;

View File

@@ -1,11 +1,33 @@
import { useEffect } from "react";
import TimerWidget from "../TimerWidget";
import type { Clue } from "../store/cluesSlice"; import type { Clue } from "../store/cluesSlice";
import { socket } from "../socket";
import { useTimer } from "../hooks";
interface Props { interface Props {
activeClue: Clue; activeClue: Clue;
} }
const ClueDisplay = ({ activeClue }: Props) => { const ClueDisplay = ({ activeClue }: Props) => {
return <div className="text-center fs-1">{activeClue.question}</div>; const onTimeout = () => {
socket.emit("clue-clock-timeout");
};
const { start, cancel, segs } = useTimer(5000, onTimeout);
useEffect(() => {
socket.on("clue-clock-on", start);
socket.on("contestant-buzzed", cancel);
socket.on("contestant-penalized", start);
}, []);
return (
<>
<div className="text-center fs-1">
{activeClue.question}
</div>
<TimerWidget segs={segs} />
</>
);
}; };
export default ClueDisplay; export default ClueDisplay;

View File

@@ -14,8 +14,9 @@ const Gameboard = ({ categories }: Props) => {
<Row> <Row>
{categories.map(({ name }) => ( {categories.map(({ name }) => (
<Col <Col
xs={2}
key={name} key={name}
className="fw-bolder border border-primary text-center" className="fw-bolder border border-2 border-dark bg-primary text-center text-white text-uppercase p-4"
> >
{name} {name}
</Col> </Col>
@@ -24,7 +25,11 @@ const Gameboard = ({ categories }: Props) => {
{values.map((value) => ( {values.map((value) => (
<Row key={value}> <Row key={value}>
{categories.map(({ name }) => ( {categories.map(({ name }) => (
<Col key={name} className="text-center border border-primary"> <Col
xs={2}
key={name}
className="text-center border border-dark bg-primary text-white p-4"
>
{value} {value}
</Col> </Col>
))} ))}

View File

@@ -1,5 +1,49 @@
import { useState, useRef } from "react";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store"; import type { RootState, AppDispatch } from "./store";
import { clamp } from "lodash";
export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
const invLerp = (a: number, b: number, x: number): number =>
clamp((x - b) / (a - b), 0, 1);
interface Timer {
segs: number;
start: () => void;
cancel: () => void;
}
export const useTimer = (length: number, onTimeout?: () => unknown): Timer => {
const [segs, setSegs] = useState(0);
const timeoutId = useRef<ReturnType<typeof setTimeout>>();
const updateTimer = (startTime: number) => {
const endTime = startTime + length;
const currentTime = Date.now();
const segs = invLerp(startTime, endTime, currentTime) * 5;
setSegs(segs);
if (segs > 0) {
timeoutId.current = setTimeout(updateTimer, 100, startTime);
} else if (onTimeout) {
onTimeout();
}
};
const start = () => {
const startTime = Date.now();
timeoutId.current = setTimeout(updateTimer, 100, startTime);
};
const cancel = () => {
clearTimeout(timeoutId.current);
setSegs(0);
};
return {
segs,
start,
cancel,
};
};

View File

@@ -1,24 +1,54 @@
import { useState, useEffect } from "react";
import { Container, Stack, Button } from "react-bootstrap"; import { Container, Stack, Button } from "react-bootstrap";
import { useAppDispatch } from "../hooks";
import { setActiveClue } from "../store/cluesSlice";
import type { Clue } from "../store/cluesSlice"; import type { Clue } from "../store/cluesSlice";
import { socket } from "../socket";
import { debounce } from "../utils";
interface Props { interface Props {
activeClue: Clue; activeClue: Clue;
} }
// TODO implement timer type Mode = "reading" | "running" | "buzzed";
const ActiveClue = ({ activeClue }: Props) => { const ActiveClue = ({ activeClue }: Props) => {
const dispatch = useAppDispatch(); const [mode, setMode] = useState<Mode>("reading");
useEffect(() => {
socket.on("clue-clock-on", () => {
setMode("running");
});
socket.on("contestant-buzzed", () => {
setMode("buzzed");
});
}, []);
const startTimer = debounce(() => socket.emit("start-clue-clock"));
const contestantCorrect = debounce(() => socket.emit("contestant-correct"));
const contestantIncorrect = debounce(() => {
setMode("running");
socket.emit("contestant-incorrect");
});
return ( return (
<Container> <Container>
<p>{activeClue.question}</p> <p>{activeClue.question}</p>
<p className="fst-italic">{activeClue.answer}</p>
<Stack gap={3} className="text-center"> <Stack gap={3} className="text-center">
<Button onClick={() => dispatch(setActiveClue(null))}> {mode === "reading" && (
Start Timer <Button onClick={startTimer}>Start Timer</Button>
)}
{mode === "running" && ( // TODO remove this
<Button
variant="warning"
onClick={() => socket.emit("clue-clock-timeout")}
>
Cancel
</Button> </Button>
)}
{mode === "buzzed" && (
<>
<Button onClick={contestantCorrect}>Correct</Button>
<Button onClick={contestantIncorrect}>Incorrect</Button>
</>
)}
</Stack> </Stack>
</Container> </Container>
); );

View File

@@ -18,7 +18,7 @@ const CluesDisplay = () => {
<Stack gap={3} className="text-center"> <Stack gap={3} className="text-center">
{categories.map(({ name }) => ( {categories.map(({ name }) => (
<Fragment key={name}> <Fragment key={name}>
<h2>{name}</h2> <h2 className="text-uppercase">{name}</h2>
{[200, 400, 600, 800, 1000].map((value) => ( {[200, 400, 600, 800, 1000].map((value) => (
<Button key={value} onClick={() => activateClue(name, value)}> <Button key={value} onClick={() => activateClue(name, value)}>
{value} {value}

View File

@@ -1,12 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { setup } from "./socket"; import { setup } from "./socket";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App /> <App />
</React.StrictMode>
); );
setup(); setup();

View File

@@ -1,6 +1,6 @@
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import store from "./store"; import store from "./store";
import { setRoomCode } from "./store/socketSlice"; import { setRoomCode } from "./store/commonSlice";
import { addContestant } from "./store/displaySlice"; import { addContestant } from "./store/displaySlice";
import { setCategories, setActiveClue } from "./store/cluesSlice"; import { setCategories, setActiveClue } from "./store/cluesSlice";
import type { Clue } from "./store/cluesSlice"; import type { Clue } from "./store/cluesSlice";

View File

@@ -38,10 +38,14 @@ export const cluesSlice = createSlice({
setActiveClue: (state, { payload }: PayloadAction<Clue | null>) => { setActiveClue: (state, { payload }: PayloadAction<Clue | null>) => {
state.activeClue = payload; state.activeClue = payload;
}, },
clearActiveClue: (state) => {
state.activeClue = null;
},
}, },
}); });
export const { setCategories, setActiveClue } = cluesSlice.actions; export const { setCategories, setActiveClue, clearActiveClue } =
cluesSlice.actions;
export const selectCategories = (state: RootState) => state.clues.categories; export const selectCategories = (state: RootState) => state.clues.categories;
export const selectActiveClue = (state: RootState) => state.clues.activeClue; export const selectActiveClue = (state: RootState) => state.clues.activeClue;

View File

@@ -1,16 +1,16 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { RootState } from "./"; import type { RootState } from "./";
interface SocketState { interface CommonState {
roomCode: string; roomCode: string;
} }
const initialState: SocketState = { const initialState: CommonState = {
roomCode: "", roomCode: "",
}; };
export const socketSlice = createSlice({ export const commonSlice = createSlice({
name: "socket", name: "common",
initialState, initialState,
reducers: { reducers: {
setRoomCode: (state, { payload }: PayloadAction<string>) => { setRoomCode: (state, { payload }: PayloadAction<string>) => {
@@ -19,7 +19,7 @@ export const socketSlice = createSlice({
}, },
}); });
export const { setRoomCode } = socketSlice.actions; export const { setRoomCode } = commonSlice.actions;
export const selectRoomCode = (state: RootState) => state.socket.roomCode; export const selectRoomCode = (state: RootState) => state.common.roomCode;
export default socketSlice.reducer; export default commonSlice.reducer;

View File

@@ -3,12 +3,10 @@ import type { RootState } from "./";
interface ContestantState { interface ContestantState {
signature: number[][]; signature: number[][];
canBuzz: boolean;
} }
const initialState: ContestantState = { const initialState: ContestantState = {
signature: [], signature: [],
canBuzz: false,
}; };
export const contestantSlice = createSlice({ export const contestantSlice = createSlice({
@@ -18,14 +16,10 @@ export const contestantSlice = createSlice({
addPathToSignature: (state, { payload }: PayloadAction<number[]>) => { addPathToSignature: (state, { payload }: PayloadAction<number[]>) => {
state.signature = [...state.signature, payload]; state.signature = [...state.signature, payload];
}, },
setCanBuzz: (state, { payload }: PayloadAction<boolean>) => {
state.canBuzz = payload;
},
}, },
}); });
export const { addPathToSignature } = contestantSlice.actions; export const { addPathToSignature } = contestantSlice.actions;
export const selectSignature = (state: RootState) => state.contestant.signature; export const selectSignature = (state: RootState) => state.contestant.signature;
export const selectCanBuzz = (state: RootState) => state.contestant.canBuzz;
export default contestantSlice.reducer; export default contestantSlice.reducer;

View File

@@ -1,12 +1,12 @@
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit";
import socketReducer from "./socketSlice"; import commonReducer from "./commonSlice";
import contestantReducer from "./contestantSlice"; import contestantReducer from "./contestantSlice";
import displayReducer from "./displaySlice"; import displayReducer from "./displaySlice";
import cluesReducer from "./cluesSlice"; import cluesReducer from "./cluesSlice";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
socket: socketReducer, common: commonReducer,
contestant: contestantReducer, contestant: contestantReducer,
display: displayReducer, display: displayReducer,
clues: cluesReducer, clues: cluesReducer,