diff options
Diffstat (limited to 'frontend/app/components')
| -rw-r--r-- | frontend/app/components/ExecStatusIndicatorIcon.tsx | 8 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.client.tsx | 99 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx | 117 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApp.client.tsx | 104 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx | 140 | ||||
| -rw-r--r-- | frontend/app/components/SubmitStatusLabel.tsx | 26 |
6 files changed, 296 insertions, 198 deletions
diff --git a/frontend/app/components/ExecStatusIndicatorIcon.tsx b/frontend/app/components/ExecStatusIndicatorIcon.tsx index a76e957..5277bfa 100644 --- a/frontend/app/components/ExecStatusIndicatorIcon.tsx +++ b/frontend/app/components/ExecStatusIndicatorIcon.tsx @@ -1,17 +1,23 @@ import { faBan, + faCircle, faCircleCheck, faCircleExclamation, faRotate, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import type { ExecResultStatus } from "../models/ExecResult"; type Props = { - status: string; + status: ExecResultStatus; }; export default function ExecStatusIndicatorIcon({ status }: Props) { switch (status) { + case "waiting_submission": + return ( + <FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" /> + ); case "running": return ( <FontAwesomeIcon diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx index 4aebd52..d527e07 100644 --- a/frontend/app/components/GolfPlayApp.client.tsx +++ b/frontend/app/components/GolfPlayApp.client.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; +import type { PlayerInfo } from "../models/PlayerInfo"; import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting"; import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished"; import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; @@ -12,14 +13,17 @@ type GamePlayerMessageS2C = components["schemas"]["GamePlayerMessageS2C"]; type GamePlayerMessageC2S = components["schemas"]["GamePlayerMessageC2S"]; type Game = components["schemas"]["Game"]; +type User = components["schemas"]["User"]; type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; export default function GolfPlayApp({ game, + player, sockToken, }: { game: Game; + player: User; sockToken: string; }) { const socketUrl = @@ -39,16 +43,17 @@ export default function GolfPlayApp({ const [leftTimeSeconds, setLeftTimeSeconds] = useState<number | null>(null); useEffect(() => { - if (gameState === "starting" && startedAt !== null) { + if ( + (gameState === "starting" || gameState === "gaming") && + startedAt !== null + ) { const timer1 = setInterval(() => { setLeftTimeSeconds((prev) => { if (prev === null) { return null; } if (prev <= 1) { - clearInterval(timer1); setGameState("gaming"); - return 0; } return prev - 1; }); @@ -70,9 +75,21 @@ export default function GolfPlayApp({ } }, [gameState, startedAt, game.duration_seconds]); - const [currentScore, setCurrentScore] = useState<number | null>(null); - - const [lastExecStatus, setLastExecStatus] = useState<string | null>(null); + const [playerInfo, setPlayerInfo] = useState<Omit<PlayerInfo, "code">>({ + displayName: player.display_name, + iconPath: player.icon_path ?? null, + score: null, + submitResult: { + status: "waiting_submission", + execResults: game.exec_steps.map((r) => ({ + testcase_id: r.testcase_id, + status: "waiting_submission", + label: r.label, + stdout: "", + stderr: "", + })), + }, + }); const onCodeChange = useDebouncedCallback((code: string) => { console.log("player:c2s:code"); @@ -83,11 +100,26 @@ export default function GolfPlayApp({ }, 1000); const onCodeSubmit = useDebouncedCallback((code: string) => { + if (code === "") { + return; + } console.log("player:c2s:submit"); sendJsonMessage({ type: "player:c2s:submit", data: { code }, }); + setPlayerInfo((prev) => ({ + ...prev, + submitResult: { + status: "running", + execResults: prev.submitResult.execResults.map((r) => ({ + ...r, + status: "running", + stdout: "", + stderr: "", + })), + }, + })); }, 1000); if (readyState === ReadyState.UNINSTANTIATED) { @@ -117,14 +149,46 @@ export default function GolfPlayApp({ setGameState("starting"); } } else if (lastJsonMessage.type === "player:s2c:execresult") { + const { testcase_id, status, stdout, stderr } = lastJsonMessage.data; + setPlayerInfo((prev) => { + const ret = { ...prev }; + ret.submitResult = { + ...prev.submitResult, + execResults: prev.submitResult.execResults.map((r) => + r.testcase_id === testcase_id && r.status === "running" + ? { + ...r, + status, + stdout, + stderr, + } + : r, + ), + }; + return ret; + }); + } else if (lastJsonMessage.type === "player:s2c:submitresult") { const { status, score } = lastJsonMessage.data; - if ( - score !== null && - (currentScore === null || score < currentScore) - ) { - setCurrentScore(score); - } - setLastExecStatus(status); + setPlayerInfo((prev) => { + const ret = { ...prev }; + ret.submitResult = { + ...prev.submitResult, + status, + }; + if (status === "success") { + if (score) { + if (ret.score === null || score < ret.score) { + ret.score = score; + } + } + } else { + ret.submitResult.execResults = prev.submitResult.execResults.map( + (r) => + r.status === "running" ? { ...r, status: "canceled" } : r, + ); + } + return ret; + }); } } else { if (game.started_at) { @@ -133,7 +197,7 @@ export default function GolfPlayApp({ // The game has already started. if (gameState !== "gaming" && gameState !== "finished") { setStartedAt(game.started_at); - setLeftTimeSeconds(0); + setLeftTimeSeconds(game.started_at - nowSec); setGameState("gaming"); } } else { @@ -159,7 +223,6 @@ export default function GolfPlayApp({ lastJsonMessage, readyState, gameState, - currentScore, ]); if (gameState === "connecting") { @@ -171,12 +234,14 @@ export default function GolfPlayApp({ } else if (gameState === "gaming") { return ( <GolfPlayAppGaming + gameDisplayName={game.display_name} + gameDurationSeconds={game.duration_seconds} + leftTimeSeconds={leftTimeSeconds!} + playerInfo={playerInfo} problemTitle={game.problem.title} problemDescription={game.problem.description} onCodeChange={onCodeChange} onCodeSubmit={onCodeSubmit} - currentScore={currentScore} - lastExecStatus={lastExecStatus} /> ); } else if (gameState === "finished") { diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index 31927a5..4730583 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,21 +1,33 @@ +import { faArrowDown } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@remix-run/react"; import React, { useRef } from "react"; +import SubmitButton from "../../components/SubmitButton"; +import type { PlayerInfo } from "../../models/PlayerInfo"; +import BorderedContainer from "../BorderedContainer"; +import ExecStatusIndicatorIcon from "../ExecStatusIndicatorIcon"; +import SubmitStatusLabel from "../SubmitStatusLabel"; type Props = { + gameDisplayName: string; + gameDurationSeconds: number; + leftTimeSeconds: number; + playerInfo: Omit<PlayerInfo, "code">; problemTitle: string; problemDescription: string; onCodeChange: (code: string) => void; onCodeSubmit: (code: string) => void; - currentScore: number | null; - lastExecStatus: string | null; }; export default function GolfPlayAppGaming({ + gameDisplayName, + gameDurationSeconds, + leftTimeSeconds, + playerInfo, problemTitle, problemDescription, onCodeChange, onCodeSubmit, - currentScore, - lastExecStatus, }: Props) { const textareaRef = useRef<HTMLTextAreaElement>(null); @@ -29,36 +41,81 @@ export default function GolfPlayAppGaming({ } }; + const leftTime = (() => { + const k = gameDurationSeconds + leftTimeSeconds; + const m = Math.floor(k / 60); + const s = k % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + })(); + return ( - <div className="min-h-screen flex"> - <div className="mx-auto flex min-h-full flex-grow"> - <div className="flex w-1/2 flex-col justify-between p-4"> - <div> - <div className="mb-2 text-xl font-bold">{problemTitle}</div> - <div className="text-gray-700">{problemDescription}</div> - </div> - <div className="mb-4 mt-auto"> - <div className="mb-2"> - <div className="font-semibold text-green-500"> - Score: {currentScore ?? "-"} ({lastExecStatus ?? "-"}) - </div> - </div> - <button - onClick={handleSubmitButtonClick} - className="focus:shadow-outline rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none" - > - Submit - </button> + <div className="min-h-screen bg-gray-100 flex flex-col"> + <div className="text-white bg-iosdc-japan flex flex-row justify-between px-4 py-2"> + <div className="font-bold"> + <div className="text-gray-100">{gameDisplayName}</div> + <div className="text-2xl">{leftTime}</div> + </div> + <div className="font-bold text-end"> + <Link to={"/dashboard"} className="text-gray-100"> + {playerInfo.displayName} + </Link> + <div className="text-2xl">{playerInfo.score}</div> + </div> + </div> + <div className="grow grid grid-cols-3 divide-x divide-gray-300"> + <div className="p-4"> + <div className="mb-2 text-xl font-bold">{problemTitle}</div> + <div className="p-2"> + <BorderedContainer> + <div className="text-gray-700">{problemDescription}</div> + </BorderedContainer> </div> </div> - <div className="w-1/2 p-4 flex"> - <div className="flex-grow"> - <textarea - ref={textareaRef} - onChange={handleTextChange} - className="h-full w-full rounded-lg border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500" - ></textarea> + <div className="p-4"> + <textarea + ref={textareaRef} + onChange={handleTextChange} + className="resize-none h-full w-full rounded-lg border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition duration-300" + ></textarea> + </div> + <div className="p-4 flex flex-col gap-4"> + <div className="flex"> + <SubmitButton onClick={handleSubmitButtonClick}>提出</SubmitButton> + <div className="grow font-bold text-xl text-center m-1"> + <SubmitStatusLabel status={playerInfo.submitResult.status} /> + </div> </div> + <ul className="flex flex-col gap-2"> + {playerInfo.submitResult.execResults.map((r, idx) => ( + <li key={r.testcase_id ?? -1} className="flex gap-2"> + <div className="flex flex-col gap-2 p-2"> + <div className="w-6"> + <ExecStatusIndicatorIcon status={r.status} /> + </div> + {idx !== playerInfo.submitResult.execResults.length - 1 && ( + <div> + <FontAwesomeIcon + icon={faArrowDown} + fixedWidth + className="text-gray-500" + /> + </div> + )} + </div> + <div className="grow p-2 overflow-x-scroll"> + <BorderedContainer> + <div className="font-semibold">{r.label}</div> + <div> + <code> + {r.stdout} + {r.stderr} + </code> + </div> + </BorderedContainer> + </div> + </li> + ))} + </ul> </div> </div> </div> diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 448a966..b2f3b69 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -1,11 +1,10 @@ import { useEffect, useState } from "react"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; +import type { PlayerInfo } from "../models/PlayerInfo"; import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting"; import GolfWatchAppFinished from "./GolfWatchApps/GolfWatchAppFinished"; -import GolfWatchAppGaming, { - PlayerInfo, -} from "./GolfWatchApps/GolfWatchAppGaming"; +import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming"; import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting"; @@ -40,16 +39,17 @@ export default function GolfWatchApp({ const [leftTimeSeconds, setLeftTimeSeconds] = useState<number | null>(null); useEffect(() => { - if (gameState === "starting" && startedAt !== null) { + if ( + (gameState === "starting" || gameState === "gaming") && + startedAt !== null + ) { const timer1 = setInterval(() => { setLeftTimeSeconds((prev) => { if (prev === null) { return null; } if (prev <= 1) { - clearInterval(timer1); setGameState("gaming"); - return 0; } return prev - 1; }); @@ -79,14 +79,32 @@ export default function GolfWatchApp({ iconPath: playerA?.icon_path ?? null, score: null, code: "", - submissionResult: undefined, + submitResult: { + status: "waiting_submission", + execResults: game.exec_steps.map((r) => ({ + testcase_id: r.testcase_id, + status: "waiting_submission", + label: r.label, + stdout: "", + stderr: "", + })), + }, }); const [playerInfoB, setPlayerInfoB] = useState<PlayerInfo>({ displayName: playerB?.display_name ?? null, iconPath: playerB?.icon_path ?? null, score: null, code: "", - submissionResult: undefined, + submitResult: { + status: "waiting_submission", + execResults: game.exec_steps.map((r) => ({ + testcase_id: r.testcase_id, + status: "waiting_submission", + label: r.label, + stdout: "", + stderr: "", + })), + }, }); if (readyState === ReadyState.UNINSTANTIATED) { @@ -121,18 +139,16 @@ export default function GolfWatchApp({ player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; setter((prev) => ({ ...prev, code })); } else if (lastJsonMessage.type === "watcher:s2c:submit") { - const { player_id, preliminary_score } = lastJsonMessage.data; + const { player_id } = lastJsonMessage.data; const setter = player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; setter((prev) => ({ ...prev, - submissionResult: { + submitResult: { status: "running", - preliminaryScore: preliminary_score, - verificationResults: game.verification_steps.map((v) => ({ - testcase_id: v.testcase_id, + execResults: prev.submitResult.execResults.map((r) => ({ + ...r, status: "running", - label: v.label, stdout: "", stderr: "", })), @@ -145,50 +161,42 @@ export default function GolfWatchApp({ player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; setter((prev) => { const ret = { ...prev }; - if (ret.submissionResult === undefined) { - return ret; - } - ret.submissionResult = { - ...ret.submissionResult, - verificationResults: ret.submissionResult.verificationResults.map( - (v) => - v.testcase_id === testcase_id && v.status === "running" - ? { - ...v, - status, - stdout, - stderr, - } - : v, + ret.submitResult = { + ...prev.submitResult, + execResults: prev.submitResult.execResults.map((r) => + r.testcase_id === testcase_id && r.status === "running" + ? { + ...r, + status, + stdout, + stderr, + } + : r, ), }; return ret; }); } else if (lastJsonMessage.type === "watcher:s2c:submitresult") { - const { player_id, status } = lastJsonMessage.data; + const { player_id, status, score } = lastJsonMessage.data; const setter = player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; setter((prev) => { const ret = { ...prev }; - if (ret.submissionResult === undefined) { - return ret; - } - ret.submissionResult = { - ...ret.submissionResult, + ret.submitResult = { + ...prev.submitResult, status, }; if (status === "success") { - if ( - ret.score === null || - ret.submissionResult.preliminaryScore < ret.score - ) { - ret.score = ret.submissionResult.preliminaryScore; + if (score) { + if (ret.score === null || score < ret.score) { + ret.score = score; + } } } else { - ret.submissionResult.verificationResults = - ret.submissionResult.verificationResults.map((v) => - v.status === "running" ? { ...v, status: "canceled" } : v, - ); + ret.submitResult.execResults = prev.submitResult.execResults.map( + (r) => + r.status === "running" ? { ...r, status: "canceled" } : r, + ); } return ret; }); @@ -200,7 +208,7 @@ export default function GolfWatchApp({ // The game has already started. if (gameState !== "gaming" && gameState !== "finished") { setStartedAt(game.started_at); - setLeftTimeSeconds(0); + setLeftTimeSeconds(game.started_at - nowSec); setGameState("gaming"); } } else { @@ -221,7 +229,6 @@ export default function GolfWatchApp({ } } }, [ - game.verification_steps, game.started_at, lastJsonMessage, readyState, @@ -239,10 +246,11 @@ export default function GolfWatchApp({ } else if (gameState === "gaming") { return ( <GolfWatchAppGaming - problem={game.problem!.description} + gameDurationSeconds={game.duration_seconds} + leftTimeSeconds={leftTimeSeconds!} playerInfoA={playerInfoA} playerInfoB={playerInfoB} - leftTimeSeconds={leftTimeSeconds!} + problem={game.problem!.description} /> ); } else if (gameState === "finished") { diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx index 65cd35e..f9647b3 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx @@ -1,83 +1,29 @@ +import { PlayerInfo } from "../../models/PlayerInfo"; import ExecStatusIndicatorIcon from "../ExecStatusIndicatorIcon"; +import SubmitStatusLabel from "../SubmitStatusLabel"; type Props = { - problem: string; + gameDurationSeconds: number; + leftTimeSeconds: number; playerInfoA: PlayerInfo; playerInfoB: PlayerInfo; - leftTimeSeconds: number; -}; - -export type PlayerInfo = { - displayName: string | null; - iconPath: string | null; - score: number | null; - code: string | null; - submissionResult?: SubmissionResult; -}; - -type SubmissionResult = { - status: - | "running" - | "success" - | "wrong_answer" - | "timeout" - | "compile_error" - | "runtime_error" - | "internal_error"; - preliminaryScore: number; - verificationResults: VerificationResult[]; -}; - -type VerificationResult = { - testcase_id: number | null; - status: - | "running" - | "success" - | "wrong_answer" - | "timeout" - | "compile_error" - | "runtime_error" - | "internal_error" - | "canceled"; - label: string; - stdout: string; - stderr: string; + problem: string; }; -function submissionResultStatusToLabel( - status: SubmissionResult["status"] | null, -) { - switch (status) { - case null: - return "-"; - case "running": - return "Running..."; - case "success": - return "Accepted"; - case "wrong_answer": - return "Wrong Answer"; - case "timeout": - return "Time Limit Exceeded"; - case "compile_error": - return "Compile Error"; - case "runtime_error": - return "Runtime Error"; - case "internal_error": - return "Internal Error"; - } -} - export default function GolfWatchAppGaming({ - problem, + gameDurationSeconds, + leftTimeSeconds, playerInfoA, playerInfoB, - leftTimeSeconds, + problem, }: Props) { const leftTime = (() => { - const m = Math.floor(leftTimeSeconds / 60); - const s = leftTimeSeconds % 60; + const k = gameDurationSeconds + leftTimeSeconds; + const m = Math.floor(k / 60); + const s = k % 60; return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; })(); + const scoreRatio = (() => { const scoreA = playerInfoA.score ?? 0; const scoreB = playerInfoB.score ?? 0; @@ -118,29 +64,24 @@ export default function GolfWatchAppGaming({ </div> <div> <div> - {submissionResultStatusToLabel( - playerInfoA.submissionResult?.status ?? null, - )}{" "} - ({playerInfoA.submissionResult?.preliminaryScore}) + <SubmitStatusLabel status={playerInfoA.submitResult.status} /> </div> <div> <ol> - {playerInfoA.submissionResult?.verificationResults.map( - (result) => ( - <li key={result.testcase_id ?? -1}> + {playerInfoA.submitResult?.execResults.map((result) => ( + <li key={result.testcase_id ?? -1}> + <div> + <div> + <ExecStatusIndicatorIcon status={result.status} />{" "} + {result.label} + </div> <div> - <div> - <ExecStatusIndicatorIcon status={result.status} />{" "} - {result.label} - </div> - <div> - {result.stdout} - {result.stderr} - </div> + {result.stdout} + {result.stderr} </div> - </li> - ), - )} + </div> + </li> + ))} </ol> </div> </div> @@ -151,29 +92,24 @@ export default function GolfWatchAppGaming({ </div> <div> <div> - {submissionResultStatusToLabel( - playerInfoB.submissionResult?.status ?? null, - )}{" "} - ({playerInfoB.submissionResult?.preliminaryScore ?? "-"}) + <SubmitStatusLabel status={playerInfoB.submitResult.status} /> </div> <div> <ol> - {playerInfoB.submissionResult?.verificationResults.map( - (result, idx) => ( - <li key={idx}> + {playerInfoB.submitResult?.execResults.map((result, idx) => ( + <li key={idx}> + <div> + <div> + <ExecStatusIndicatorIcon status={result.status} />{" "} + {result.label} + </div> <div> - <div> - <ExecStatusIndicatorIcon status={result.status} />{" "} - {result.label} - </div> - <div> - {result.stdout} - {result.stderr} - </div> + {result.stdout} + {result.stderr} </div> - </li> - ), - )} + </div> + </li> + ))} </ol> </div> </div> diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx new file mode 100644 index 0000000..e0ecc27 --- /dev/null +++ b/frontend/app/components/SubmitStatusLabel.tsx @@ -0,0 +1,26 @@ +import type { SubmitResultStatus } from "../models/SubmitResult"; + +type Props = { + status: SubmitResultStatus; +}; + +export default function SubmitStatusLabel({ status }: Props) { + switch (status) { + case "waiting_submission": + return null; + case "running": + return "実行中..."; + case "success": + return "成功"; + case "wrong_answer": + return "テスト失敗"; + case "timeout": + return "時間切れ"; + case "compile_error": + return "コンパイルエラー"; + case "runtime_error": + return "実行時エラー"; + case "internal_error": + return "!内部エラー!"; + } +} |
