pashto-grammar/src/games/GameCore.tsx

263 lines
9.9 KiB
TypeScript
Raw Normal View History

2022-05-30 19:43:15 +00:00
import { useState, useRef, useEffect } from "react";
2021-09-10 23:23:29 +00:00
import { CountdownCircleTimer } from "react-countdown-circle-timer";
import Reward, { RewardElement } from 'react-rewards';
import Link from "../components/Link";
2021-09-18 04:43:00 +00:00
import { useUser } from "../user-context";
2021-09-10 23:23:29 +00:00
import "./timer.css";
import {
getPercentageDone,
} from "../lib/game-utils";
import {
saveResult,
postSavedResults,
} from "../lib/game-results";
import {
AT,
getTimestamp,
} from "@lingdocs/lingdocs-main";
2021-09-18 04:43:00 +00:00
import {
2022-05-30 19:43:15 +00:00
randFromArray,
2021-10-11 00:39:59 +00:00
Types,
2021-09-18 04:43:00 +00:00
} from "@lingdocs/pashto-inflector";
2022-05-10 17:25:27 +00:00
import ReactGA from "react-ga";
2022-05-11 03:52:31 +00:00
import { isProd } from "../lib/isProd";
2022-05-30 19:43:15 +00:00
import autoAnimate from "@formkit/auto-animate";
2021-09-10 23:23:29 +00:00
const errorVibration = 200;
2022-05-30 19:16:48 +00:00
const maxStrikes = 2;
2022-05-09 22:02:24 +00:00
function GameCore<T>({ questions, Display, timeLimit, Instructions, studyLink, id }:{
2021-09-18 04:43:00 +00:00
id: string,
studyLink: string,
2021-10-11 00:39:59 +00:00
Instructions: (props: { opts?: Types.TextOptions }) => JSX.Element,
2021-09-18 04:43:00 +00:00
questions: () => QuestionGenerator<T>,
Display: (props: QuestionDisplayProps<T>) => JSX.Element,
timeLimit: number;
}) {
// TODO: report pass with id to user info
2021-09-10 23:23:29 +00:00
const rewardRef = useRef<RewardElement | null>(null);
2022-05-30 19:43:15 +00:00
const parent = useRef<HTMLDivElement | null>(null);
const { user, pullUser, setUser } = useUser();
2022-05-09 17:57:56 +00:00
const [finish, setFinish] = useState<undefined | "pass" | { msg: "fail", answer: JSX.Element } | "time out">(undefined);
2022-05-30 19:16:48 +00:00
const [strikes, setStrikes] = useState<number>(0);
const [justStruck, setJustStruck] = useState<boolean>(false);
2021-09-10 23:23:29 +00:00
const [current, setCurrent] = useState<Current<T> | undefined>(undefined);
const [questionBox, setQuestionBox] = useState<QuestionGenerator<T>>(questions());
const [timerKey, setTimerKey] = useState<number>(1);
2022-05-30 19:43:15 +00:00
useEffect(() => {
parent.current && autoAnimate(parent.current)
}, [parent]);
2021-09-10 23:23:29 +00:00
2022-05-11 03:52:31 +00:00
function logGameEvent(action: string) {
if (isProd && !(user?.admin)) {
2022-05-10 17:25:27 +00:00
ReactGA.event({
category: "Game",
2022-05-11 03:52:31 +00:00
action: `${action} - ${id}`,
2022-05-10 17:25:27 +00:00
label: id,
});
2022-05-11 03:52:31 +00:00
}
}
function handleCallback(correct: true | JSX.Element) {
2022-05-30 19:43:15 +00:00
if (correct === true) {
handleAdvance();
return;
}
setStrikes(s => s + 1);
navigator.vibrate(errorVibration);
if (strikes < maxStrikes) {
setJustStruck(true);
2022-05-30 19:16:48 +00:00
} else {
2022-05-11 03:52:31 +00:00
logGameEvent("fail on game");
2022-05-09 17:57:56 +00:00
setFinish({ msg: "fail", answer: correct });
}
2021-09-10 23:23:29 +00:00
}
function handleAdvance() {
2022-05-30 19:16:48 +00:00
setJustStruck(false);
2021-09-10 23:23:29 +00:00
const next = questionBox.next();
if (next.done) handleFinish();
else setCurrent(next.value);
}
function handleResult(result: AT.TestResult) {
// add the test to the user object
if (!user) return;
setUser((u) => {
// pure type safety with the prevUser
if (!u) return u;
return {
...u,
tests: [...u.tests, result],
};
});
// save the test result in local storage
saveResult(result, user.userId);
// try to post the result
postSavedResults(user.userId).then((r) => {
if (r === "sent") pullUser();
}).catch(console.error);
}
2021-09-10 23:23:29 +00:00
function handleFinish() {
2022-05-11 03:52:31 +00:00
logGameEvent("passed game")
setFinish("pass");
rewardRef.current?.rewardMe();
2021-09-19 03:30:15 +00:00
if (!user) return;
const result: AT.TestResult = {
done: true,
time: getTimestamp(),
id,
};
handleResult(result);
2021-09-10 23:23:29 +00:00
}
function handleQuit() {
2022-05-09 17:57:56 +00:00
setFinish(undefined);
2021-09-10 23:23:29 +00:00
setCurrent(undefined);
}
function handleRestart() {
2022-05-11 03:52:31 +00:00
logGameEvent("started game");
2021-09-10 23:23:29 +00:00
const newQuestionBox = questions();
const { value } = newQuestionBox.next();
// just for type safety -- the generator will have at least one question
if (!value) return;
setQuestionBox(newQuestionBox);
2022-05-30 19:43:15 +00:00
setJustStruck(false);
setStrikes(0);
2022-05-09 17:57:56 +00:00
setFinish(undefined);
2021-09-10 23:23:29 +00:00
setCurrent(value);
setTimerKey(prev => prev + 1);
}
function handleTimeOut() {
2022-05-11 03:52:31 +00:00
logGameEvent("timeout on game")
2021-09-10 23:23:29 +00:00
setFinish("time out");
navigator.vibrate(errorVibration);
}
function getProgressWidth(): string {
const num = !current
? 0
: (finish === "pass")
? 100
: getPercentageDone(current.progress);
return `${num}%`;
}
const progressColor = finish === "pass"
? "success"
2022-05-09 17:57:56 +00:00
: typeof finish === "object"
2021-09-10 23:23:29 +00:00
? "danger"
: "primary";
2022-05-10 18:12:02 +00:00
const gameRunning = current && finish === undefined;
2022-05-09 22:02:24 +00:00
return <>
<div className="text-center" style={{ minHeight: "200px", zIndex: 10, position: "relative" }}>
2021-09-10 23:23:29 +00:00
<div className="progress" style={{ height: "5px" }}>
<div className={`progress-bar bg-${progressColor}`} role="progressbar" style={{ width: getProgressWidth() }} />
</div>
2022-05-30 19:43:15 +00:00
{current && <div className="d-flex flex-row justify-content-between mt-2">
<StrikesDisplay strikes={strikes} />
<div className="d-flex flex-row-reverse">
<CountdownCircleTimer
key={timerKey}
isPlaying={!!current && !finish}
size={30}
colors={["#555555", "#F7B801", "#A30000"]}
colorsTime={[timeLimit, timeLimit*0.33, 0]}
strokeWidth={4}
strokeLinecap="square"
duration={timeLimit}
onComplete={handleTimeOut}
/>
<button onClick={handleQuit} className="btn btn-outline-secondary btn-sm mr-2">Quit</button>
</div>
2021-09-10 23:23:29 +00:00
</div>}
2022-05-30 19:43:15 +00:00
<div ref={parent}>
{justStruck && <div className="alert alert-warning my-2" role="alert" style={{ maxWidth: "300px", margin: "0 auto" }}>
{getStrikeMessage()}
</div>}
</div>
2022-05-10 21:48:13 +00:00
<Reward ref={rewardRef} config={{ lifetime: 130, spread: 90, elementCount: 150, zIndex: 999999999 }} type="confetti">
<div>
2022-05-09 17:57:56 +00:00
{finish === undefined &&
2021-09-10 23:23:29 +00:00
(current
? <div>
<Display question={current.question} callback={handleCallback} />
</div>
: <div>
<div className="pt-3">
{/* TODO: ADD IN TEXT DISPLAY OPTIONS HERE TOO - WHEN WE START USING THEM*/}
<Instructions />
</div>
<div>
<button className="btn btn-primary mt-4" onClick={handleRestart}>Start</button>
</div>
</div>)
}
{finish === "pass" && <div>
<h4 className="mt-4">
<span role="img" aria-label="celebration">🎉</span> Finished!
</h4>
<button className="btn btn-secondary mt-4" onClick={handleRestart}>Try Again</button>
</div>}
2022-05-09 17:57:56 +00:00
{(typeof finish === "object" || finish === "time out") && <div>
2021-09-10 23:23:29 +00:00
<h4 className="mt-4">{failMessage(current?.progress, finish)}</h4>
2022-05-09 17:57:56 +00:00
{typeof finish === "object" && <div>
<div>The correct answer was:</div>
2022-05-10 21:48:13 +00:00
<div className="my-2">
{finish?.answer}
</div>
2022-05-09 17:57:56 +00:00
</div>}
2022-05-09 22:02:24 +00:00
<div className="mt-3">
<button className="btn btn-success mr-2" onClick={handleRestart}>Try Again</button>
<button className="btn btn-danger ml-2" onClick={handleQuit}>Quit</button>
2021-09-10 23:23:29 +00:00
</div>
2022-05-09 22:02:24 +00:00
<div onClick={handleQuit} className="my-3">
2021-09-10 23:23:29 +00:00
<Link to={studyLink}>
<button className="btn btn-outline-secondary"><span role="img" aria-label="">📚</span> Study more</button>
</Link>
</div>
</div>}
</div>
</Reward>
</div>
2022-05-10 18:12:02 +00:00
{gameRunning && <div style={{
2022-05-09 22:02:24 +00:00
position: "absolute",
backgroundColor: "rgba(255, 255, 255, 0.3)",
backdropFilter: "blur(10px)",
top: "0px",
left: "0px",
width: "100%",
height: "100%",
zIndex: 6,
}}></div>}
</>;
2021-09-10 23:23:29 +00:00
}
2022-05-30 19:43:15 +00:00
function StrikesDisplay({ strikes }: { strikes: number }) {
return <div>
{[...Array(strikes)].map(_ => <span key={Math.random()} className="mr-2"></span>)}
</div>;
}
function getStrikeMessage() {
return randFromArray([
"Not quite! Try again.",
"No sorry, try again",
"Umm, no, try again",
"Try again",
"Oooooooo, sorry no...",
]);
}
2022-05-09 17:57:56 +00:00
function failMessage(progress: Progress | undefined, finish: "time out" | { msg: "fail", answer: JSX.Element }): string {
2021-09-10 23:23:29 +00:00
const pDone = progress ? getPercentageDone(progress) : 0;
const { message, face } = pDone < 20
? { message: "No, sorry", face: "😑" }
: pDone < 30
? { message: "Oops, that's wrong", face: "😟" }
: pDone < 55
? { message: "Fail", face: "😕" }
: pDone < 78
? { message: "You almost got it!", face: "😩" }
: { message: "Nooo! So close!", face: "😭" };
2022-05-09 17:57:56 +00:00
return typeof finish === "object"
2021-09-10 23:23:29 +00:00
? `${message} ${face}`
: `⏳ Time's Up ${face}`;
}
2021-09-18 04:43:00 +00:00
export default GameCore;