init
This commit is contained in:
25
src/App.tsx
Normal file
25
src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "./store";
|
||||
|
||||
import Error from "./Error";
|
||||
import Landing from "./Landing";
|
||||
import Display from "./Display";
|
||||
import Contestant from "./Contestant";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="new-game" element={<Display />} />
|
||||
<Route path="contestant-join/:room" element={<Contestant />} />
|
||||
<Route path="/*" element={<Error />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
53
src/Contestant.tsx
Normal file
53
src/Contestant.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { socket } from "./socket";
|
||||
import { useAppSelector } from "./hooks";
|
||||
import { selectCanBuzz, selectSignature } from "./store/contestantSlice";
|
||||
|
||||
const Contestant = () => {
|
||||
const { room } = useParams();
|
||||
|
||||
const signature = useAppSelector(selectSignature);
|
||||
const canBuzz = useAppSelector(selectCanBuzz);
|
||||
|
||||
useEffect(() => {
|
||||
socket.emit("contestant-join", { room, signature });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (parent.current) {
|
||||
setWidth(parent.current.getBoundingClientRect().width);
|
||||
}
|
||||
}, [parent]);
|
||||
|
||||
const handleBuzz = debounce(
|
||||
() => {
|
||||
if (canBuzz) {
|
||||
socket.emit("buzz");
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 100 100`}>
|
||||
<circle
|
||||
cx={50}
|
||||
cy={50}
|
||||
r={30}
|
||||
fill={canBuzz ? "red" : "grey"}
|
||||
stroke="black"
|
||||
onClick={handleBuzz}
|
||||
/>
|
||||
<text x={50} y={50} textAnchor="middle">
|
||||
Buzz
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contestant;
|
||||
27
src/ContestantWidget.tsx
Normal file
27
src/ContestantWidget.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import DrawPad from "./DrawPad";
|
||||
import type { Contestant } from "./store/displaySlice";
|
||||
|
||||
interface Props {
|
||||
contestant: Contestant;
|
||||
}
|
||||
|
||||
const ContestantWidget = ({ contestant }: Props) => (
|
||||
<span style={{ minWidth: "30%" }}>
|
||||
<h2 className="text-center">${String(contestant.score)}</h2>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
backgroundColor: contestant.active
|
||||
? "yellow"
|
||||
: contestant.failed
|
||||
? "red"
|
||||
: "black",
|
||||
minWidth: "30%",
|
||||
}}
|
||||
>
|
||||
<DrawPad lines={contestant.signature} height={200} readonly />
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default ContestantWidget;
|
||||
36
src/Display.tsx
Normal file
36
src/Display.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from "react";
|
||||
import { Stack, Container } from "react-bootstrap";
|
||||
|
||||
import { socket } from "./socket";
|
||||
import { useAppSelector } from "./hooks";
|
||||
import { selectRoomCode } from "./store/socketSlice";
|
||||
import { selectContestants } from "./store/displaySlice";
|
||||
import ContestantWidget from "./ContestantWidget";
|
||||
|
||||
const Display = () => {
|
||||
const roomCode = useAppSelector(selectRoomCode);
|
||||
const contestants = useAppSelector(selectContestants);
|
||||
|
||||
useEffect(() => {
|
||||
socket.emit("new-game");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<h1 className="text-center">Room code is: {roomCode}</h1>
|
||||
</Container>
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
gap={10}
|
||||
className="position-absolute bottom-0 w-100 d-flex justify-content-center"
|
||||
>
|
||||
{Object.entries(contestants).map(([sid, contestant]) => (
|
||||
<ContestantWidget key={sid} contestant={contestant} />
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Display;
|
||||
83
src/DrawPad.tsx
Normal file
83
src/DrawPad.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
height: number,
|
||||
lines: number[][],
|
||||
onUpdateImage?: (path: number[]) => unknown,
|
||||
readonly?: boolean,
|
||||
};
|
||||
|
||||
const cursorPos = (e) => {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
return [e.clientX - rect.left, e.clientY - rect.top];
|
||||
};
|
||||
|
||||
const DrawPad = ({ height, lines, onUpdateImage, readonly = false }: Props) => {
|
||||
const parent = useRef();
|
||||
const canvas = useRef();
|
||||
const [width, setWidth] = useState(0);
|
||||
const [down, setDown] = useState(false);
|
||||
const [path, setPath] = useState([]);
|
||||
const pen = canvas.current?.getContext("2d");
|
||||
|
||||
const render = () => {
|
||||
if (pen) {
|
||||
pen.clearRect(0, 0, width, height);
|
||||
pen.fillStyle = "blue";
|
||||
pen.fillRect(0, 0, width, height);
|
||||
pen.lineWidth = 3;
|
||||
pen.lineCap = "round";
|
||||
pen.strokeStyle = "white";
|
||||
lines.forEach((path) => {
|
||||
if (path.length > 2) {
|
||||
pen.moveTo(path[0], path[1]);
|
||||
}
|
||||
for (let i = 2; i < path.length; i += 2) {
|
||||
pen.lineTo(path[i], path[i + 1]);
|
||||
pen.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setWidth(parent.current?.getBoundingClientRect().width);
|
||||
render();
|
||||
}, [parent, pen, lines]);
|
||||
|
||||
const beginLine = () => {
|
||||
if (readonly) return;
|
||||
pen.beginPath();
|
||||
setDown(true);
|
||||
};
|
||||
|
||||
const drawLine = (e) => {
|
||||
if (!down) return;
|
||||
const [x, y] = cursorPos(e);
|
||||
setPath([...path, x, y]);
|
||||
pen.lineTo(x, y);
|
||||
pen.stroke();
|
||||
};
|
||||
|
||||
const endLine = () => {
|
||||
setDown(false);
|
||||
onUpdateImage?.(path);
|
||||
setPath([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={parent}>
|
||||
<canvas
|
||||
width={width}
|
||||
height={height}
|
||||
ref={canvas}
|
||||
onPointerDown={beginLine}
|
||||
onPointerMove={drawLine}
|
||||
onPointerUp={endLine}
|
||||
style={{ touchAction: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawPad;
|
||||
12
src/Error.tsx
Normal file
12
src/Error.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Container } from "react-bootstrap";
|
||||
|
||||
const Error = () => (
|
||||
<Container className="text-center">
|
||||
<h1>Oops!</h1>
|
||||
<p>
|
||||
I don't think you meant to come here, why don't you go back?
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
|
||||
export default Error;
|
||||
83
src/Landing.tsx
Normal file
83
src/Landing.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Container, Stack, Button, Modal, Form } from "react-bootstrap";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "./hooks";
|
||||
import { addPathToSignature, selectSignature } from "./store/contestantSlice";
|
||||
|
||||
import DrawPad from "./DrawPad";
|
||||
|
||||
const Landing = () => {
|
||||
const [isContestant, setIsContestant] = useState(false);
|
||||
const [isHost, setIsHost] = useState(false);
|
||||
const [roomCode, setRoomCode] = useState("");
|
||||
const dispatch = useAppDispatch();
|
||||
const signature = useAppSelector(selectSignature);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Stack gap={3} className="text-center">
|
||||
<h1>Venture</h1>
|
||||
<Link to={"/new-game"}>
|
||||
<Button>New Game</Button>
|
||||
</Link>
|
||||
<span>
|
||||
<Button onClick={() => setIsContestant(true)}>
|
||||
Join as Contestant
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button onClick={() => setIsHost(true)}>Join as Host</Button>
|
||||
</span>
|
||||
</Stack>
|
||||
</Container>
|
||||
<Modal show={isContestant}>
|
||||
<Modal.Body>
|
||||
<Form className="text-center">
|
||||
<Form.Group className="text-start">
|
||||
<Form.Label>Room Code</Form.Label>
|
||||
<Form.Control
|
||||
type={"text"}
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="text-start">
|
||||
<Form.Label>Name</Form.Label>
|
||||
<DrawPad
|
||||
height={200}
|
||||
lines={signature}
|
||||
onUpdateImage={(path: number[]) =>
|
||||
dispatch(addPathToSignature(path))
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Link to={`/contestant-join/${roomCode}`}>
|
||||
<Button className="m-3">Submit</Button>
|
||||
</Link>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal show={isHost}>
|
||||
<Modal.Body>
|
||||
<Form className="text-center">
|
||||
<Form.Group className="text-start">
|
||||
<Form.Label>Room Code</Form.Label>
|
||||
<Form.Control
|
||||
type={"text"}
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Link to={`/host-join/${roomCode}`}>
|
||||
<Button className="m-3">Submit</Button>
|
||||
</Link>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Landing;
|
||||
5
src/hooks.ts
Normal file
5
src/hooks.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
import type { RootState, AppDispatch } from "./store";
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
12
src/main.tsx
Normal file
12
src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { setup } from './socket';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
setup();
|
||||
20
src/socket.ts
Normal file
20
src/socket.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { io } from "socket.io-client";
|
||||
import store from "./store";
|
||||
import { setRoomCode } from "./store/socketSlice";
|
||||
import { addContestant } from "./store/displaySlice";
|
||||
|
||||
export const socket = io(`${window.location.hostname}:5000`);
|
||||
|
||||
export const setup = () => {
|
||||
socket.on("connect", () => {
|
||||
console.log("Connected to socket");
|
||||
});
|
||||
|
||||
socket.on("set-code", ({ code }) => {
|
||||
store.dispatch(setRoomCode(code));
|
||||
});
|
||||
|
||||
socket.on("contestant-joined", (data) => {
|
||||
store.dispatch(addContestant(data));
|
||||
});
|
||||
};
|
||||
31
src/store/contestantSlice.ts
Normal file
31
src/store/contestantSlice.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { RootState } from "./";
|
||||
|
||||
interface ContestantState {
|
||||
signature: number[][];
|
||||
canBuzz: boolean;
|
||||
}
|
||||
|
||||
const initialState: ContestantState = {
|
||||
signature: [],
|
||||
canBuzz: false,
|
||||
};
|
||||
|
||||
export const contestantSlice = createSlice({
|
||||
name: "contestant",
|
||||
initialState,
|
||||
reducers: {
|
||||
addPathToSignature: (state, { payload }: PayloadAction<number[]>) => {
|
||||
state.signature = [...state.signature, payload];
|
||||
},
|
||||
setCanBuzz: (state, { payload }: PayloadAction<boolean>) => {
|
||||
state.canBuzz = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addPathToSignature } = contestantSlice.actions;
|
||||
export const selectSignature = (state: RootState) => state.contestant.signature;
|
||||
export const selectCanBuzz = (state: RootState) => state.contestant.canBuzz;
|
||||
|
||||
export default contestantSlice.reducer;
|
||||
41
src/store/displaySlice.ts
Normal file
41
src/store/displaySlice.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { RootState } from "./";
|
||||
|
||||
interface DisplayState {
|
||||
contestants: Record<string, Contestant>;
|
||||
}
|
||||
|
||||
interface Contestant {
|
||||
signature: number[][];
|
||||
score: number;
|
||||
active: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
const initialState: DisplayState = {
|
||||
contestants: {},
|
||||
};
|
||||
|
||||
export const displaySlice = createSlice({
|
||||
name: "display",
|
||||
initialState,
|
||||
reducers: {
|
||||
addContestant: (
|
||||
state,
|
||||
{
|
||||
payload: { sid, signature },
|
||||
}: PayloadAction<{ sid: string; signature: number[][] }>
|
||||
) => {
|
||||
state.contestants = {
|
||||
...state.contestants,
|
||||
[sid]: { signature, score: 0, active: false, failed: false },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addContestant } = displaySlice.actions;
|
||||
export const selectContestants = (state: RootState) =>
|
||||
state.display.contestants;
|
||||
|
||||
export default displaySlice.reducer;
|
||||
17
src/store/index.ts
Normal file
17
src/store/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import socketReducer from "./socketSlice";
|
||||
import contestantReducer from "./contestantSlice";
|
||||
import displayReducer from "./displaySlice";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
socket: socketReducer,
|
||||
contestant: contestantReducer,
|
||||
display: displayReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export default store;
|
||||
25
src/store/socketSlice.ts
Normal file
25
src/store/socketSlice.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { RootState } from "./";
|
||||
|
||||
interface SocketState {
|
||||
roomCode: string;
|
||||
}
|
||||
|
||||
const initialState: SocketState = {
|
||||
roomCode: "",
|
||||
};
|
||||
|
||||
export const socketSlice = createSlice({
|
||||
name: "socket",
|
||||
initialState,
|
||||
reducers: {
|
||||
setRoomCode: (state, { payload }: PayloadAction<string>) => {
|
||||
state.roomCode = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setRoomCode } = socketSlice.actions;
|
||||
export const selectRoomCode = (state: RootState) => state.socket.roomCode;
|
||||
|
||||
export default socketSlice.reducer;
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user